pub mod exec;
use std::{
fmt,
net::IpAddr,
pin::Pin,
sync::{Arc, mpsc},
time::Duration,
};
#[cfg(target_os = "macos")]
use std::{
os::fd::{FromRawFd, RawFd},
sync::atomic::{AtomicBool, Ordering},
task::{Context, Poll},
};
use tokio::io::{AsyncBufRead, AsyncReadExt};
#[cfg(target_os = "macos")]
use tokio::io::{AsyncBufReadExt, ReadBuf};
use crate::core::client::Client;
use crate::core::containers::request::DEFAULT_STARTUP_TIMEOUT;
use crate::core::host::Host;
use crate::{
ContainerRequest, Image,
core::{
WaitFor,
copy::CopyFileFromContainer,
error::{Error, Result, WaitContainerError},
image::{ContainerState, ExecCommand},
ports::{ContainerPort, Ports},
},
};
#[cfg(target_os = "macos")]
use crate::core::copy::CopyFromContainerError;
use crate::core::error::ExecError;
pub(crate) enum ContainerLogSource {
None,
#[cfg(target_os = "macos")]
Fd { stdout: RawFd, stderr: RawFd },
#[cfg(target_os = "linux")]
DockerStream(Arc<crate::core::client::docker_log_stream::DockerLogsHandle>),
}
pub struct ContainerAsync<I: Image> {
id: String,
image: ContainerRequest<I>,
client: Client,
dropped: bool,
wait_state: Arc<std::sync::Mutex<WaitState>>,
log_source: std::sync::Mutex<ContainerLogSource>,
#[cfg(target_os = "macos")]
log_stop: std::sync::Mutex<Arc<AtomicBool>>,
log_consumers: Option<Arc<Vec<Box<dyn crate::core::logs::consumer::LogConsumer + 'static>>>>,
}
#[derive(Debug, Default)]
pub(crate) struct WaitState {
generation: u64,
exit_code: Option<i64>,
}
impl WaitState {
pub(crate) fn store_if_current(&mut self, generation: u64, code: i64) -> bool {
if self.generation == generation {
self.exit_code = Some(code);
true
} else {
false
}
}
pub(crate) fn bump(&mut self) -> u64 {
self.generation = self.generation.wrapping_add(1);
self.exit_code = None;
self.generation
}
pub(crate) fn exit_code(&self) -> Option<i64> {
self.exit_code
}
pub(crate) fn generation(&self) -> u64 {
self.generation
}
}
pub(crate) fn new_wait_state() -> Arc<std::sync::Mutex<WaitState>> {
Arc::new(std::sync::Mutex::new(WaitState::default()))
}
#[cfg(target_os = "macos")]
pub(crate) fn spawn_exit_code_waiter(
id: String,
wait_state: Arc<std::sync::Mutex<WaitState>>,
generation: u64,
) {
std::thread::spawn(move || {
if let Ok(code) = crate::core::client::xpc_client::XpcClient::wait_blocking(&id, &id) {
let mut guard = wait_state
.lock()
.expect("wait state mutex must not be poisoned while recording exit code");
let _ = guard.store_if_current(generation, code);
}
});
}
#[cfg(target_os = "linux")]
pub(crate) fn spawn_exit_code_waiter(
client: std::sync::Arc<crate::core::client::docker_client::DockerClient>,
id: String,
wait_state: Arc<std::sync::Mutex<WaitState>>,
generation: u64,
) {
std::thread::spawn(move || {
if let Ok(code) = client.wait_blocking(&id) {
let mut guard = wait_state
.lock()
.expect("wait state mutex must not be poisoned while recording exit code");
let _ = guard.store_if_current(generation, code);
}
});
}
impl<I: Image> ContainerAsync<I> {
pub(crate) fn new(
id: String,
client: Client,
#[cfg_attr(target_os = "linux", expect(unused_mut))] mut image: ContainerRequest<I>,
wait_state: Arc<std::sync::Mutex<WaitState>>,
log_source: ContainerLogSource,
#[cfg(target_os = "linux")] log_consumers: Option<
Arc<Vec<Box<dyn crate::core::logs::consumer::LogConsumer + 'static>>>,
>,
) -> Self {
#[cfg(target_os = "macos")]
let (log_stop, log_consumers) = {
let taken = std::mem::take(&mut image.log_consumers);
let log_stop = Arc::new(AtomicBool::new(false));
let log_consumers = if taken.is_empty() {
None
} else {
let consumers = Arc::new(taken);
let (out_fd, err_fd) = match &log_source {
ContainerLogSource::Fd { stdout, stderr } => (Some(*stdout), Some(*stderr)),
ContainerLogSource::None => (None, None),
};
spawn_log_consumer_task(
out_fd,
log_stop.clone(),
wait_state.clone(),
consumers.clone(),
crate::core::logs::LogFrame::StdOut,
);
spawn_log_consumer_task(
err_fd,
log_stop.clone(),
wait_state.clone(),
consumers.clone(),
crate::core::logs::LogFrame::StdErr,
);
Some(consumers)
};
(std::sync::Mutex::new(log_stop), log_consumers)
};
Self {
id,
image,
client,
dropped: false,
wait_state,
log_source: std::sync::Mutex::new(log_source),
#[cfg(target_os = "macos")]
log_stop,
log_consumers,
}
}
pub fn id(&self) -> &str {
&self.id
}
pub fn image(&self) -> &I {
self.image.image()
}
pub async fn ports(&self) -> Result<Ports> {
match &self.client {
#[cfg(target_os = "macos")]
Client::MacOs(c) => c.ports(&self.id).await,
#[cfg(target_os = "linux")]
Client::Linux(c) => c.ports(&self.id).await,
}
}
pub async fn get_host_port_ipv4(&self, internal_port: impl Into<ContainerPort>) -> Result<u16> {
let internal_port = internal_port.into();
self.ports()
.await?
.map_to_host_port_ipv4(internal_port)
.ok_or_else(|| Error::PortNotExposed {
id: self.id.clone(),
port: internal_port,
})
}
pub async fn get_host_port_ipv6(&self, internal_port: impl Into<ContainerPort>) -> Result<u16> {
let internal_port = internal_port.into();
self.ports()
.await?
.map_to_host_port_ipv6(internal_port)
.ok_or_else(|| Error::PortNotExposed {
id: self.id.clone(),
port: internal_port,
})
}
pub async fn get_bridge_ip_address(&self) -> Result<IpAddr> {
match &self.client {
#[cfg(target_os = "macos")]
Client::MacOs(c) => c.bridge_ip_address(&self.id).await,
#[cfg(target_os = "linux")]
Client::Linux(c) => c.bridge_ip_address(&self.id).await,
}
}
#[cfg(target_os = "macos")]
pub(crate) async fn gateway_ip_address(&self) -> Result<IpAddr> {
match &self.client {
Client::MacOs(c) => c.gateway_ip_address(&self.id).await,
}
}
pub async fn copy_file_from<T: CopyFileFromContainer>(
&self,
source: impl Into<String> + Send,
target: T,
) -> Result<T::Output> {
match &self.client {
#[cfg(target_os = "macos")]
Client::MacOs(c) => {
let source = std::path::PathBuf::from(source.into());
let temp_path = std::env::temp_dir().join(format!(
"container-rs-copy-out-{}-{}",
self.id,
crate::core::util::unique_suffix()
));
c.copy_out(&self.id, &source, &temp_path).await?;
let result = async {
let meta = tokio::fs::metadata(&temp_path).await?;
if meta.is_dir() {
return Err(Error::other(CopyFromContainerError::IsDirectory));
}
let file = tokio::fs::File::open(&temp_path).await?;
target.copy_from_reader(file).await.map_err(Error::other)
}
.await;
remove_copy_out_temp(&temp_path).await;
result
}
#[cfg(target_os = "linux")]
Client::Linux(c) => {
let source = source.into();
if source.is_empty() || !source.starts_with('/') {
return Err(Error::other("copy_file_from path must be absolute"));
}
let tar = c.copy_from(&self.id, &source).await?;
let content =
crate::core::client::docker_tar::parse_first_regular_file_from_ustar(&tar)
.map_err(Error::other)?;
target
.copy_from_reader(std::io::Cursor::new(content))
.await
.map_err(Error::other)
}
}
}
pub async fn get_host(&self) -> Result<Host> {
Ok(Host::parse("localhost"))
}
pub async fn exec(&self, cmd: ExecCommand) -> Result<exec::ExecResult> {
let ExecCommand {
cmd,
container_ready_conditions,
cmd_ready_condition,
env_vars,
} = cmd;
if self.log_source_is_none() && ready_conditions_require_log(&container_ready_conditions) {
return Err(crate::Error::other(
"log wait requires a log source, but none is available",
));
}
let cmd_owned: Vec<String> = cmd;
let raw = match &self.client {
#[cfg(target_os = "macos")]
Client::MacOs(c) => {
let mut merged: std::collections::BTreeMap<String, String> = self
.image
.env_vars()
.map(|(k, v)| (k.into_owned(), v.into_owned()))
.collect();
for (k, v) in env_vars {
merged.insert(k, v);
}
let environment: Vec<String> = merged
.into_iter()
.map(|(k, v)| format!("{k}={v}"))
.collect();
c.exec(&self.id, &cmd_owned, environment).await?
}
#[cfg(target_os = "linux")]
Client::Linux(c) => {
if env_vars.is_empty() {
c.exec(&self.id, &cmd_owned, Vec::new()).await?
} else {
let container_env = c.container_env(&self.id).await?;
let mut merged: std::collections::BTreeMap<String, String> = container_env
.iter()
.filter_map(|s| {
let (k, v) = s.split_once('=')?;
Some((k.to_string(), v.to_string()))
})
.collect();
for (k, v) in env_vars {
merged.insert(k, v);
}
let env: Vec<String> = merged
.into_iter()
.map(|(k, v)| format!("{k}={v}"))
.collect();
c.exec(&self.id, &cmd_owned, env).await?
}
}
};
let startup_timeout = self
.image
.startup_timeout()
.unwrap_or(DEFAULT_STARTUP_TIMEOUT);
tokio::time::timeout(
startup_timeout,
self.block_until_ready(container_ready_conditions),
)
.await
.map_err(|_| WaitContainerError::StartupTimeout {
id: self.id.to_string(),
timeout: startup_timeout,
})??;
match cmd_ready_condition {
crate::core::CmdWaitFor::StdOutMessage { message } => {
if !contains_bytes(&raw.stdout, &message) {
return Err(crate::core::error::Error::other(format!(
"expected message not found in stdout: {}",
String::from_utf8_lossy(&message)
)));
}
}
crate::core::CmdWaitFor::StdErrMessage { message } => {
if !contains_bytes(&raw.stderr, &message) {
return Err(crate::core::error::Error::other(format!(
"expected message not found in stderr: {}",
String::from_utf8_lossy(&message)
)));
}
}
crate::core::CmdWaitFor::Exit { code: None } => {
}
crate::core::CmdWaitFor::Exit { code: Some(code) } => {
match raw.exit_code {
Some(actual) if actual != code => {
return Err(ExecError::ExitCodeMismatch {
expected: code,
actual,
}
.into());
}
Some(_) => {}
None => {
return Err(crate::core::error::Error::other(
"exec exit code is unavailable, cannot verify expected exit code",
));
}
}
}
crate::core::CmdWaitFor::Duration { length } => {
tokio::time::sleep(length).await;
}
crate::core::CmdWaitFor::Nothing => {}
}
Ok(exec::ExecResult {
exit_code: raw.exit_code,
stdout: std::io::Cursor::new(raw.stdout),
stderr: std::io::Cursor::new(raw.stderr),
})
}
pub async fn start(&self) -> Result<()> {
#[cfg(target_os = "macos")]
if let Client::MacOs(c) = &self.client
&& !c.container_state(&self.id).await?.running
{
c.bootstrap_container(&self.id).await?;
c.start_process(&self.id).await?;
self.reset_wait_state_and_respawn();
if let Err(e) = self.refresh_log_streams(c).await {
if let Err(stop_err) = self.stop_with_timeout(Some(0)).await {
tracing::warn!(
"failed to stop container after log refresh failure: {stop_err}"
);
}
return Err(e);
}
}
#[cfg(target_os = "linux")]
if let Client::Linux(c) = &self.client
&& !c.container_state(&self.id).await?.running
{
self.refresh_log_streams(c).await?;
self.reset_wait_state_and_respawn();
}
let state = self.container_state().await?;
for cmd in self.image.exec_after_start(state)? {
self.exec(cmd).await?;
}
Ok(())
}
#[cfg(target_os = "macos")]
fn reset_wait_state_and_respawn(&self) {
let generation = self
.wait_state
.lock()
.expect("wait state mutex must not be poisoned while restarting container")
.bump();
spawn_exit_code_waiter(self.id.clone(), self.wait_state.clone(), generation);
}
#[cfg(target_os = "linux")]
fn reset_wait_state_and_respawn(&self) {
let generation = self
.wait_state
.lock()
.expect("wait state mutex must not be poisoned while restarting container")
.bump();
let Client::Linux(c) = &self.client;
spawn_exit_code_waiter(
c.clone(),
self.id.clone(),
self.wait_state.clone(),
generation,
);
}
#[cfg(target_os = "macos")]
async fn refresh_log_streams(
&self,
client: &crate::core::client::xpc_client::XpcClient,
) -> Result<()> {
let (new_out, new_err) = client.logs(&self.id).await.map_err(|e| {
crate::Error::other(format!("failed to refresh log fds after restart: {e}"))
})?;
self.log_stop
.lock()
.expect("log stop mutex must not be poisoned while refreshing log file descriptors")
.store(true, Ordering::Relaxed);
let new_stop = Arc::new(AtomicBool::new(false));
*self
.log_stop
.lock()
.expect("log stop mutex must not be poisoned while refreshing log file descriptors") =
new_stop.clone();
{
let mut source = self
.log_source
.lock()
.expect("log source mutex must not be poisoned while refreshing logs");
if let ContainerLogSource::Fd { stdout, stderr } = *source {
unsafe { libc::close(stdout) };
unsafe { libc::close(stderr) };
}
*source = ContainerLogSource::Fd {
stdout: new_out,
stderr: new_err,
};
}
if let Some(consumers) = &self.log_consumers {
spawn_log_consumer_task(
Some(new_out),
new_stop.clone(),
self.wait_state.clone(),
consumers.clone(),
crate::core::logs::LogFrame::StdOut,
);
spawn_log_consumer_task(
Some(new_err),
new_stop,
self.wait_state.clone(),
consumers.clone(),
crate::core::logs::LogFrame::StdErr,
);
}
Ok(())
}
#[cfg(target_os = "linux")]
async fn refresh_log_streams(
&self,
client: &crate::core::client::docker_client::DockerClient,
) -> Result<()> {
use crate::core::client::docker_log_stream::spawn_log_consumer_task;
let old_handle = {
let source = self
.log_source
.lock()
.expect("log source mutex must not be poisoned while refreshing logs");
match &*source {
ContainerLogSource::DockerStream(handle) => Some(handle.clone()),
ContainerLogSource::None => None,
}
};
if let Some(handle) = &old_handle {
handle.stop();
}
client.start_container(&self.id).await?;
if old_handle.is_none() {
return Ok(());
}
let new_handle = match client.spawn_log_session(&self.id).await {
Ok(handle) => handle,
Err(e) => {
if let Err(stop_err) = client.stop(&self.id, Some(0)).await {
tracing::warn!(
"failed to stop container after log refresh failure: {stop_err}"
);
}
return Err(e);
}
};
{
let mut source = self
.log_source
.lock()
.expect("log source mutex must not be poisoned while refreshing logs");
*source = ContainerLogSource::DockerStream(new_handle.clone());
}
if let Some(consumers) = &self.log_consumers {
spawn_log_consumer_task(
new_handle.clone(),
new_handle.stdout_stream(),
consumers.clone(),
crate::core::logs::LogFrame::StdOut,
);
spawn_log_consumer_task(
new_handle.clone(),
new_handle.stderr_stream(),
consumers.clone(),
crate::core::logs::LogFrame::StdErr,
);
}
Ok(())
}
pub async fn stop(&self) -> Result<()> {
self.stop_with_timeout(None).await
}
pub async fn stop_with_timeout(&self, timeout_seconds: Option<i32>) -> Result<()> {
self.stop_log_delivery();
match &self.client {
#[cfg(target_os = "macos")]
Client::MacOs(c) => c.stop(&self.id, timeout_seconds).await,
#[cfg(target_os = "linux")]
Client::Linux(c) => c.stop(&self.id, timeout_seconds).await,
}
}
#[cfg(target_os = "linux")]
pub async fn pause(&self) -> Result<()> {
match &self.client {
Client::Linux(c) => c.pause(&self.id).await,
}
}
#[cfg(target_os = "linux")]
pub async fn unpause(&self) -> Result<()> {
match &self.client {
Client::Linux(c) => c.unpause(&self.id).await,
}
}
fn stop_log_delivery(&self) {
#[cfg(target_os = "macos")]
{
self.log_stop
.lock()
.expect("log stop mutex must not be poisoned while stopping log delivery")
.store(true, Ordering::Relaxed);
}
#[cfg(target_os = "linux")]
{
let source = self
.log_source
.lock()
.expect("log source mutex must not be poisoned while stopping log delivery");
if let ContainerLogSource::DockerStream(handle) = &*source {
handle.stop();
}
}
}
pub async fn is_running(&self) -> Result<bool> {
match &self.client {
#[cfg(target_os = "macos")]
Client::MacOs(c) => Ok(c.container_state(&self.id).await?.running),
#[cfg(target_os = "linux")]
Client::Linux(c) => Ok(c.container_state(&self.id).await?.running),
}
}
pub(crate) fn exit_code_hint(&self) -> Option<i64> {
self.wait_state
.lock()
.expect("wait state mutex must not be poisoned while reading exit code")
.exit_code()
}
pub(crate) fn logs_terminated(&self) -> bool {
#[cfg(target_os = "macos")]
{
false
}
#[cfg(target_os = "linux")]
{
let source = self
.log_source
.lock()
.expect("log source mutex must not be poisoned while checking log termination");
match &*source {
ContainerLogSource::DockerStream(handle) => handle.logs_terminated(),
ContainerLogSource::None => false,
}
}
}
pub async fn exit_code(&self) -> Result<Option<i64>> {
if let Some(code) = self
.wait_state
.lock()
.expect("wait state mutex must not be poisoned while reading exit code")
.exit_code()
{
return Ok(Some(code));
}
match &self.client {
#[cfg(target_os = "macos")]
Client::MacOs(c) => {
if c.container_state(&self.id).await?.running {
Ok(None)
} else {
let id = self.id.clone();
let wait_state = self.wait_state.clone();
let generation = self
.wait_state
.lock()
.expect("wait state mutex must not be poisoned")
.generation();
let result = tokio::task::spawn_blocking(move || {
crate::core::client::xpc_client::XpcClient::wait_blocking_with_timeout(
&id,
&id,
std::time::Duration::from_secs(5),
)
})
.await;
match result {
Ok(Ok(code)) => {
let mut guard = wait_state.lock().expect(
"wait state mutex must not be poisoned while recording exit code",
);
let _ = guard.store_if_current(generation, code);
Ok(Some(code))
}
_ => Ok(None),
}
}
}
#[cfg(target_os = "linux")]
Client::Linux(c) => {
let _ = c.container_state(&self.id).await?.running;
Ok(None)
}
}
}
#[cfg(target_os = "macos")]
pub async fn rm(mut self) -> Result<()> {
self.stop_log_delivery();
match &self.client {
Client::MacOs(c) => c.remove(&self.id, true).await?,
}
self.dropped = true;
Ok(())
}
#[cfg(target_os = "linux")]
pub async fn rm(mut self) -> Result<()> {
self.stop_log_delivery();
match &self.client {
Client::Linux(c) => c.remove(&self.id, true).await?,
}
self.dropped = true;
Ok(())
}
pub fn rm_blocking(mut self) -> Result<()> {
self.stop_log_delivery();
match &self.client {
#[cfg(target_os = "macos")]
Client::MacOs(_) => {
crate::core::client::xpc_client::XpcClient::remove_blocking(&self.id, true)?
}
#[cfg(target_os = "linux")]
Client::Linux(c) => c.remove_blocking(&self.id, true)?,
}
self.dropped = true;
Ok(())
}
pub fn stdout(&self, follow: bool) -> Pin<Box<dyn AsyncBufRead + Send>> {
let source = self
.log_source
.lock()
.expect("log source mutex must not be poisoned while reading logs");
match &*source {
ContainerLogSource::None => Box::pin(tokio::io::BufReader::new(tokio::io::empty())),
#[cfg(target_os = "macos")]
ContainerLogSource::Fd { stdout, .. } => fd_reader_or_empty(
Some(*stdout),
follow,
self.log_stop_flag(),
self.wait_state.clone(),
),
#[cfg(target_os = "linux")]
ContainerLogSource::DockerStream(handle) => {
if follow {
Box::pin(handle.stdout_reader())
} else {
Box::pin(tokio::io::BufReader::new(handle.stdout_oneshot()))
}
}
}
}
pub fn stderr(&self, follow: bool) -> Pin<Box<dyn AsyncBufRead + Send>> {
let source = self
.log_source
.lock()
.expect("log source mutex must not be poisoned while reading logs");
match &*source {
ContainerLogSource::None => Box::pin(tokio::io::BufReader::new(tokio::io::empty())),
#[cfg(target_os = "macos")]
ContainerLogSource::Fd { stderr, .. } => fd_reader_or_empty(
Some(*stderr),
follow,
self.log_stop_flag(),
self.wait_state.clone(),
),
#[cfg(target_os = "linux")]
ContainerLogSource::DockerStream(handle) => {
if follow {
Box::pin(handle.stderr_reader())
} else {
Box::pin(tokio::io::BufReader::new(handle.stderr_oneshot()))
}
}
}
}
pub(crate) fn stdout_sync(&self, follow: bool) -> Box<dyn std::io::BufRead + Send> {
let source = self
.log_source
.lock()
.expect("log source mutex must not be poisoned while reading logs");
match &*source {
ContainerLogSource::None => Box::new(std::io::BufReader::new(std::io::empty())),
#[cfg(target_os = "macos")]
ContainerLogSource::Fd { stdout, .. } => fd_reader_or_empty_sync(
Some(*stdout),
follow,
self.log_stop_flag(),
self.wait_state.clone(),
),
#[cfg(target_os = "linux")]
ContainerLogSource::DockerStream(handle) => {
if follow {
Box::new(std::io::BufReader::new(handle.stdout_sync_reader()))
} else {
Box::new(std::io::BufReader::new(handle.stdout_sync_oneshot()))
}
}
}
}
pub(crate) fn stderr_sync(&self, follow: bool) -> Box<dyn std::io::BufRead + Send> {
let source = self
.log_source
.lock()
.expect("log source mutex must not be poisoned while reading logs");
match &*source {
ContainerLogSource::None => Box::new(std::io::BufReader::new(std::io::empty())),
#[cfg(target_os = "macos")]
ContainerLogSource::Fd { stderr, .. } => fd_reader_or_empty_sync(
Some(*stderr),
follow,
self.log_stop_flag(),
self.wait_state.clone(),
),
#[cfg(target_os = "linux")]
ContainerLogSource::DockerStream(handle) => {
if follow {
Box::new(std::io::BufReader::new(handle.stderr_sync_reader()))
} else {
Box::new(std::io::BufReader::new(handle.stderr_sync_oneshot()))
}
}
}
}
#[cfg(target_os = "macos")]
fn log_stop_flag(&self) -> Arc<AtomicBool> {
self.log_stop
.lock()
.expect("log stop mutex must not be poisoned while creating log readers")
.clone()
}
pub async fn stdout_to_vec(&self) -> Result<Vec<u8>> {
let mut stdout = Vec::new();
self.stdout(false).read_to_end(&mut stdout).await?;
Ok(stdout)
}
pub async fn stderr_to_vec(&self) -> Result<Vec<u8>> {
let mut stderr = Vec::new();
self.stderr(false).read_to_end(&mut stderr).await?;
Ok(stderr)
}
fn log_source_is_none(&self) -> bool {
let source = self
.log_source
.lock()
.expect("log source mutex must not be poisoned");
matches!(*source, ContainerLogSource::None)
}
pub(crate) async fn block_until_ready(&self, ready_conditions: Vec<WaitFor>) -> Result<()> {
for condition in ready_conditions {
condition.wait_until_ready(&self.client, self).await?;
}
Ok(())
}
pub async fn container_state(&self) -> Result<ContainerState> {
match &self.client {
#[cfg(target_os = "macos")]
Client::MacOs(c) => {
let ports = c.container_state(&self.id).await?.ports;
let host = self.get_host().await?;
Ok(ContainerState::new(self.id.clone(), host, ports))
}
#[cfg(target_os = "linux")]
Client::Linux(c) => {
let ports = c.container_state(&self.id).await?.ports;
let host = self.get_host().await?;
Ok(ContainerState::new(self.id.clone(), host, ports))
}
}
}
}
fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool {
if needle.is_empty() {
return true;
}
haystack.windows(needle.len()).any(|w| w == needle)
}
pub(crate) fn ready_conditions_require_log(ready_conditions: &[WaitFor]) -> bool {
ready_conditions
.iter()
.any(|c| matches!(c, WaitFor::Log(_)))
}
#[cfg(target_os = "macos")]
struct FdReader {
file: std::fs::File,
pos: u64,
}
#[cfg(target_os = "macos")]
impl FdReader {
fn dup_from(fd: RawFd) -> Option<Self> {
let duped = unsafe { libc::dup(fd) };
if duped < 0 {
return None;
}
Some(Self {
file: unsafe { std::fs::File::from_raw_fd(duped) },
pos: 0,
})
}
fn read_at(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
use std::os::unix::fs::FileExt;
let n = loop {
match self.file.read_at(buf, self.pos) {
Ok(n) => break n,
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Err(e) => return Err(e),
}
};
self.pos += n as u64;
Ok(n)
}
}
#[cfg(target_os = "macos")]
impl std::io::Read for FdReader {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
self.read_at(buf)
}
}
#[cfg(target_os = "macos")]
impl tokio::io::AsyncRead for FdReader {
fn poll_read(
self: Pin<&mut Self>,
_cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<std::io::Result<()>> {
let this = self.get_mut();
let dst = buf.initialize_unfilled();
match this.read_at(dst) {
Ok(n) => {
buf.advance(n);
Poll::Ready(Ok(()))
}
Err(e) => Poll::Ready(Err(e)),
}
}
}
#[cfg(target_os = "macos")]
struct FollowFdReader {
inner: FdReader,
follow: bool,
stop: Arc<AtomicBool>,
wait_state: Arc<std::sync::Mutex<WaitState>>,
delay: Option<Pin<Box<tokio::time::Sleep>>>,
}
#[cfg(target_os = "macos")]
impl FollowFdReader {
fn new(
inner: FdReader,
follow: bool,
stop: Arc<AtomicBool>,
wait_state: Arc<std::sync::Mutex<WaitState>>,
) -> Self {
Self {
inner,
follow,
stop,
wait_state,
delay: None,
}
}
fn should_stop_follow(&self) -> bool {
if self.stop.load(Ordering::Relaxed) {
return true;
}
self.wait_state
.lock()
.expect("wait state mutex must not be poisoned while following logs")
.exit_code()
.is_some()
}
}
#[cfg(target_os = "macos")]
impl std::io::Read for FollowFdReader {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
if buf.is_empty() {
return Ok(0);
}
loop {
match self.inner.read_at(buf)? {
0 if self.follow && !self.should_stop_follow() => {
std::thread::sleep(Duration::from_millis(100));
}
n => return Ok(n),
}
}
}
}
#[cfg(target_os = "macos")]
impl tokio::io::AsyncRead for FollowFdReader {
fn poll_read(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<std::io::Result<()>> {
let this = self.get_mut();
if buf.remaining() == 0 {
return Poll::Ready(Ok(()));
}
loop {
let dst = buf.initialize_unfilled();
match this.inner.read_at(dst) {
Ok(0) if this.follow && !this.should_stop_follow() => {
let delay = this.delay.get_or_insert_with(|| {
Box::pin(tokio::time::sleep(Duration::from_millis(100)))
});
match delay.as_mut().poll(cx) {
Poll::Ready(()) => {
this.delay = None;
}
Poll::Pending => return Poll::Pending,
}
}
Ok(n) => {
this.delay = None;
buf.advance(n);
return Poll::Ready(Ok(()));
}
Err(e) => {
this.delay = None;
return Poll::Ready(Err(e));
}
}
}
}
}
#[cfg(target_os = "macos")]
fn fd_reader_or_empty(
fd: Option<RawFd>,
follow: bool,
stop: Arc<AtomicBool>,
wait_state: Arc<std::sync::Mutex<WaitState>>,
) -> Pin<Box<dyn AsyncBufRead + Send>> {
match fd.and_then(FdReader::dup_from) {
Some(reader) => Box::pin(tokio::io::BufReader::new(FollowFdReader::new(
reader, follow, stop, wait_state,
))),
None => Box::pin(tokio::io::BufReader::new(tokio::io::empty())),
}
}
#[cfg(target_os = "macos")]
fn fd_reader_or_empty_sync(
fd: Option<RawFd>,
follow: bool,
stop: Arc<AtomicBool>,
wait_state: Arc<std::sync::Mutex<WaitState>>,
) -> Box<dyn std::io::BufRead + Send> {
match fd.and_then(FdReader::dup_from) {
Some(reader) => Box::new(std::io::BufReader::new(FollowFdReader::new(
reader, follow, stop, wait_state,
))),
None => Box::new(std::io::BufReader::new(std::io::empty())),
}
}
#[cfg(target_os = "macos")]
fn spawn_log_consumer_task(
fd: Option<RawFd>,
stop: Arc<AtomicBool>,
wait_state: Arc<std::sync::Mutex<WaitState>>,
consumers: Arc<Vec<Box<dyn crate::core::logs::consumer::LogConsumer + 'static>>>,
to_frame: fn(Vec<u8>) -> crate::core::logs::LogFrame,
) {
let Some(fd) = fd else { return };
let Some(reader) = FdReader::dup_from(fd) else {
return;
};
tokio::spawn(async move {
let mut reader = tokio::io::BufReader::new(reader);
let mut buf = Vec::new();
let mut exit_observed_at: Option<std::time::Instant> = None;
loop {
buf.clear();
match reader.read_until(b'\n', &mut buf).await {
Ok(0) => {
if stop.load(Ordering::Relaxed) {
break;
}
let exited = wait_state
.lock()
.expect(
"wait state mutex must not be poisoned while checking container exit",
)
.exit_code()
.is_some();
if exited {
let anchor = exit_observed_at.get_or_insert_with(std::time::Instant::now);
if anchor.elapsed() >= crate::core::wait::log_strategy::DRAIN_GRACE {
break;
}
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
Ok(_) => {
if buf.last() == Some(&b'\n') {
buf.pop();
}
if buf.last() == Some(&b'\r') {
buf.pop();
}
let frame = to_frame(std::mem::take(&mut buf));
for consumer in consumers.as_ref() {
consumer.accept(&frame).await;
}
}
Err(e) => {
tracing::warn!("log consumer read failed; stopping delivery: {e}");
break;
}
}
}
});
}
impl<I: Image + fmt::Debug> fmt::Debug for ContainerAsync<I> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ContainerAsync")
.field("id", &self.id)
.field("image", &self.image)
.field("dropped", &self.dropped)
.finish()
}
}
#[cfg(target_os = "macos")]
async fn remove_copy_out_temp(path: &std::path::Path) {
if tokio::fs::remove_file(path).await.is_ok() {
return;
}
if let Err(e) = tokio::fs::remove_dir_all(path).await {
tracing::warn!(
"failed to remove copy_file_from temp path {}: {e}",
path.display()
);
}
}
const DROP_REMOVE_TIMEOUT: Duration = Duration::from_secs(5);
impl<I: Image> Drop for ContainerAsync<I> {
fn drop(&mut self) {
self.stop_log_delivery();
#[cfg(target_os = "macos")]
{
let mut source = self
.log_source
.lock()
.expect("log source mutex must not be poisoned while dropping container");
if let ContainerLogSource::Fd { stdout, stderr } = *source {
unsafe { libc::close(stdout) };
unsafe { libc::close(stderr) };
}
*source = ContainerLogSource::None;
}
#[cfg(target_os = "linux")]
{
let handle = {
let source = self
.log_source
.lock()
.expect("log source mutex must not be poisoned while dropping container");
match &*source {
ContainerLogSource::DockerStream(handle) => Some(handle.clone()),
ContainerLogSource::None => None,
}
};
if let Some(handle) = handle
&& tokio::runtime::Handle::try_current().is_err()
{
for _ in 0..20 {
if handle.all_done() {
break;
}
std::thread::sleep(Duration::from_millis(50));
}
}
}
if self.dropped {
return;
}
if !matches!(
crate::core::env::Config.command(),
crate::core::env::Command::Remove
) {
return;
}
let id = self.id.clone();
let client = self.client.clone();
let remove = move || -> Result<()> {
match &client {
#[cfg(target_os = "macos")]
Client::MacOs(_) => {
crate::core::client::xpc_client::XpcClient::remove_blocking(&id, true)
}
#[cfg(target_os = "linux")]
Client::Linux(c) => c.remove_blocking(&id, true),
}
};
match tokio::runtime::Handle::try_current() {
Ok(_) => {
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let result = remove();
let _ = tx.send(result);
});
match rx.recv_timeout(DROP_REMOVE_TIMEOUT) {
Ok(Ok(())) => {}
Ok(Err(e)) => {
tracing::error!("failed to remove container on drop: {e}");
}
Err(mpsc::RecvTimeoutError::Timeout) => {
tracing::error!(
"timed out waiting for container removal on drop ({}s)",
DROP_REMOVE_TIMEOUT.as_secs()
);
}
Err(mpsc::RecvTimeoutError::Disconnected) => {
tracing::error!("container removal thread disconnected unexpectedly");
}
}
}
Err(_) => {
if let Err(e) = remove() {
tracing::error!("failed to remove container on drop: {e}");
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::WaitState;
#[test]
fn ready_conditions_require_log_detects_log_strategy() {
use crate::core::wait::WaitFor;
assert!(super::ready_conditions_require_log(&[
WaitFor::message_on_stdout("ready")
]));
assert!(super::ready_conditions_require_log(&[
WaitFor::seconds(1),
WaitFor::message_on_stderr("err"),
]));
assert!(super::ready_conditions_require_log(&[
WaitFor::message_on_either_std("either")
]));
assert!(!super::ready_conditions_require_log(&[]));
assert!(!super::ready_conditions_require_log(&[WaitFor::Nothing]));
assert!(!super::ready_conditions_require_log(&[WaitFor::seconds(1)]));
assert!(!super::ready_conditions_require_log(&[
WaitFor::healthcheck()
]));
}
#[tokio::test]
async fn exec_log_wait_without_log_source_returns_error() {
use crate::core::client::Client;
use crate::core::containers::async_container::{ContainerAsync, ContainerLogSource};
use crate::core::image::ExecCommand;
use crate::core::image::image_ext::ImageExt;
use crate::core::wait::WaitFor;
use crate::images::GenericImage;
let req: crate::ContainerRequest<GenericImage> =
GenericImage::new("alpine", "latest").with_cmd(["sleep", "30"]);
let wait_state = crate::core::containers::async_container::new_wait_state();
let client = Client::detect().expect("ăŻă©ă€ăąăłăçæă«ć€±æăă");
#[cfg(target_os = "linux")]
let container: ContainerAsync<GenericImage> = ContainerAsync::new(
"test-id".to_string(),
client,
req,
wait_state,
ContainerLogSource::None,
None,
);
#[cfg(target_os = "macos")]
let container: ContainerAsync<GenericImage> = ContainerAsync::new(
"test-id".to_string(),
client,
req,
wait_state,
ContainerLogSource::None,
);
let err = container
.exec(
ExecCommand::new(["echo", "hello"])
.with_container_ready_conditions(vec![WaitFor::message_on_stdout("ready")]),
)
.await
.expect_err("ăă°ććŸć
ăȘă + Log ćŸ
æ©ăŻæç€șăšă©ăŒă«ăȘăăăš");
assert!(
err.to_string().contains("log wait requires a log source"),
"ăă°ććŸć
æŹ ćŠăźæç€șăšă©ăŒă§ăăăăš: {err}"
);
}
#[test]
fn store_if_current_accepts_matching_generation() {
let mut state = WaitState::default();
assert_eq!(state.generation(), 0);
assert!(state.store_if_current(0, 42));
assert_eq!(state.exit_code(), Some(42));
}
#[test]
fn store_if_current_rejects_stale_generation() {
let mut state = WaitState::default();
assert!(state.store_if_current(0, 1));
let new_gen = state.bump();
assert_eq!(new_gen, 1);
assert_eq!(state.exit_code(), None);
assert!(!state.store_if_current(0, 99));
assert_eq!(state.exit_code(), None);
assert!(state.store_if_current(1, 7));
assert_eq!(state.exit_code(), Some(7));
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn remove_copy_out_temp_removes_nonempty_directory() {
let dir = std::env::temp_dir().join(format!(
"container-rs-copy-out-helper-test-{}",
crate::core::util::unique_suffix()
));
tokio::fs::create_dir(&dir)
.await
.expect("äžæăăŁăŹăŻăăȘăźäœæă«ć€±æăă");
tokio::fs::write(dir.join("child.txt"), b"x")
.await
.expect("ćăăĄă€ă«ăźäœæă«ć€±æăă");
assert!(
tokio::fs::metadata(&dir)
.await
.expect("äžæăăŁăŹăŻăăȘăźăĄăżăăŒăżććŸă«ć€±æăă")
.is_dir(),
"äœæçŽćŸăŻăăŁăŹăŻăăȘă§ăăăăš"
);
super::remove_copy_out_temp(&dir).await;
assert!(
tokio::fs::metadata(&dir).await.is_err(),
"ăă«ăăŒćŒăłćșăćŸă«ăăčăććšăăȘăăăš"
);
}
#[cfg(target_os = "macos")]
#[test]
fn follow_fd_reader_sync_empty_buffer_returns_zero() {
use std::io::Read;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
let dir = std::env::temp_dir().join(format!(
"container-rs-follow-fd-test-{}",
crate::core::util::unique_suffix()
));
std::fs::create_dir(&dir).expect("äžæăăŁăŹăŻăăȘăźäœæă«ć€±æăă");
let path = dir.join("test.log");
std::fs::write(&path, b"hello").expect("ăăĄă€ă«ăźæžă蟌ăżă«ć€±æăă");
let file = std::fs::File::open(&path).expect("ăăĄă€ă«ăéăăăăš");
use std::os::fd::AsRawFd;
let reader = super::FdReader::dup_from(file.as_raw_fd()).expect("dup ă«æćăăăăš");
let stop = Arc::new(AtomicBool::new(false));
let wait_state = Arc::new(std::sync::Mutex::new(WaitState::default()));
let mut follow_reader = super::FollowFdReader::new(reader, true, stop, wait_state);
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut empty: [u8; 0] = [];
let result = follow_reader.read(&mut empty);
let _ = tx.send(result);
});
let result = rx
.recv_timeout(std::time::Duration::from_secs(2))
.expect("ç©șăăăăĄăź read ă 2 ç§ä»„ć
ă«èżăăăš (çĄéă«ăŒăăăŠăăȘăăăš)");
assert_eq!(
result.expect("read ăæćăăăăš"),
0,
"ç©șăăăăĄă§ăŻ Ok(0) ă§ăăăăš"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn follow_fd_reader_async_empty_buffer_returns_ready() {
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use tokio::io::AsyncRead;
let dir = std::env::temp_dir().join(format!(
"container-rs-follow-fd-async-test-{}",
crate::core::util::unique_suffix()
));
tokio::fs::create_dir(&dir)
.await
.expect("äžæăăŁăŹăŻăăȘăźäœæă«ć€±æăă");
let path = dir.join("test.log");
tokio::fs::write(&path, b"hello")
.await
.expect("ăăĄă€ă«ăźæžă蟌ăżă«ć€±æăă");
let file = tokio::fs::File::open(&path)
.await
.expect("ăăĄă€ă«ăéăăăăš");
use std::os::fd::AsRawFd;
let reader = super::FdReader::dup_from(file.as_raw_fd()).expect("dup ă«æćăăăăš");
let stop = Arc::new(AtomicBool::new(false));
let wait_state = Arc::new(std::sync::Mutex::new(WaitState::default()));
let mut follow_reader = super::FollowFdReader::new(reader, true, stop, wait_state);
let mut buf = tokio::io::ReadBuf::new(&mut []);
let result = tokio::time::timeout(
std::time::Duration::from_secs(2),
std::future::poll_fn(|cx| {
std::pin::Pin::new(&mut follow_reader).poll_read(cx, &mut buf)
}),
)
.await
.expect("ç©șăăăăĄăź poll_read ă 2 ç§ä»„ć
ă«èżăăăš (çĄéă«ăŒăăăŠăăȘăăăš)");
assert!(result.is_ok(), "ç©șăăăăĄăź poll_read ăæćăăăăš");
assert_eq!(
buf.filled().len(),
0,
"ç©șăăăăĄă§ăŻèȘăżèŸŒăżăă€ăæ° 0 ă§ăăăăš"
);
let _ = tokio::fs::remove_dir_all(&dir).await;
}
}