use std::future::Future;
use std::time::Duration;
use bollard::Docker;
use bollard::container::LogOutput;
use bollard::exec::{CreateExecOptions, StartExecOptions, StartExecResults};
use bollard::models::ExecInspectResponse;
use futures_util::StreamExt;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt};
use super::{Canal, Error, Orden, Trozo};
pub struct Cliente {
docker: Docker,
socket: String,
timeout: Duration,
}
impl Cliente {
pub fn conectar(socket: &str, timeout: Duration) -> Result<Self, Error> {
let segundos = timeout.as_secs().max(1);
let docker = Docker::connect_with_socket(socket, segundos, bollard::API_DEFAULT_VERSION)
.map_err(|causa| Error::del_socket(socket, causa))?;
Ok(Self {
docker,
socket: socket.to_owned(),
timeout: Duration::from_secs(segundos),
})
}
pub async fn comprobar(&self) -> Result<(), Error> {
self.docker
.ping()
.await
.map(|_| ())
.map_err(|causa| Error::del_socket(&self.socket, causa))
}
pub async fn ejecutar(
&self,
contenedor: &str,
orden: &Orden,
inactividad_maxima: Duration,
mut al_llegar: impl FnMut(Trozo),
) -> Result<i32, Error> {
let opciones = CreateExecOptions {
attach_stdout: Some(true),
attach_stderr: Some(true),
tty: Some(false),
env: Some(orden.env.iter().map(String::as_str).collect()),
cmd: Some(orden.cmd.iter().map(String::as_str).collect()),
..Default::default()
};
let creado = self
.docker
.create_exec(contenedor, opciones)
.await
.map_err(|causa| Error::del_contenedor(contenedor, causa))?;
let iniciado = self
.docker
.start_exec(
&creado.id,
Some(StartExecOptions {
detach: false,
tty: false,
output_capacity: None,
}),
)
.await
.map_err(|causa| Error::del_contenedor(contenedor, causa))?;
let StartExecResults::Attached { mut output, .. } = iniciado else {
return Err(Error::SinSalida {
contenedor: contenedor.to_owned(),
});
};
loop {
let trozo = match tokio::time::timeout(inactividad_maxima, output.next()).await {
Ok(Some(trozo)) => {
trozo.map_err(|causa| Error::del_contenedor(contenedor, causa))?
}
Ok(None) => break,
Err(_) => {
return Err(Error::ExtraccionEstancada {
contenedor: contenedor.to_owned(),
inactividad: inactividad_maxima,
});
}
};
let (canal, bytes) = match trozo {
LogOutput::StdOut { message } => (Canal::Salida, message),
LogOutput::StdErr { message } => (Canal::Error, message),
LogOutput::Console { message } => {
debug_assert!(
false,
"la biblioteca entregó un `Console`: alguien encendió el tty, y con \
tty el motivo de un fallo viaja como bytes de copia"
);
(Canal::Salida, message)
}
LogOutput::StdIn { message } => {
debug_assert!(
false,
"la biblioteca entregó un `StdIn`: alguien pidió `attach_stdin`, que \
esta orden no necesita"
);
(Canal::Salida, message)
}
};
al_llegar(Trozo {
canal,
bytes: bytes.to_vec(),
});
}
let estado = self
.docker
.inspect_exec(&creado.id)
.await
.map_err(|causa| Error::del_contenedor(contenedor, causa))?;
codigo_de_salida(&estado).ok_or_else(|| Error::SinCodigoDeSalida {
contenedor: contenedor.to_owned(),
})
}
pub async fn ejecutar_con_entrada(
&self,
contenedor: &str,
orden: &Orden,
inactividad_maxima: Duration,
lector: impl AsyncRead + Unpin,
mut al_llegar: impl FnMut(Trozo),
) -> Result<i32, Error> {
let creado = self
.docker
.create_exec(contenedor, opciones_con_entrada(orden))
.await
.map_err(|causa| Error::del_contenedor(contenedor, causa))?;
let iniciado = self
.docker
.start_exec(
&creado.id,
Some(StartExecOptions {
detach: false,
tty: false,
output_capacity: None,
}),
)
.await
.map_err(|causa| Error::del_contenedor(contenedor, causa))?;
let StartExecResults::Attached { input, mut output } = iniciado else {
return Err(Error::SinSalida {
contenedor: contenedor.to_owned(),
});
};
let entrega = async {
let mut entrada = input;
let mut lector = lector;
let mut buffer = vec![0_u8; TROZO_DE_ENTRADA];
let desenlace = loop {
match lector.read(&mut buffer).await {
Ok(0) => break Ok(()),
Ok(leidos) => {
if let Err(causa) = entrada.write_all(&buffer[..leidos]).await {
break Err(std::io::Error::new(
std::io::ErrorKind::BrokenPipe,
format!("el `stdin` de la orden rechazó el volcado: {causa}"),
));
}
}
Err(causa) => break Err(causa),
}
};
let cierre = entrada.shutdown().await;
match (desenlace, cierre) {
(Err(causa), _) => Err(causa),
(Ok(()), Err(causa)) => Err(std::io::Error::new(
std::io::ErrorKind::BrokenPipe,
format!("no se pudo cerrar el `stdin` de la orden: {causa}"),
)),
(Ok(()), Ok(())) => Ok(()),
}
};
let drenaje = async {
loop {
let trozo = match tokio::time::timeout(inactividad_maxima, output.next()).await {
Ok(Some(trozo)) => {
trozo.map_err(|causa| Error::del_contenedor(contenedor, causa))?
}
Ok(None) => break,
Err(_) => {
return Err(Error::RestauracionEstancada {
contenedor: contenedor.to_owned(),
inactividad: inactividad_maxima,
});
}
};
let (canal, bytes) = match trozo {
LogOutput::StdOut { message } => (Canal::Salida, message),
LogOutput::StdErr { message } => (Canal::Error, message),
LogOutput::StdIn { .. } => continue,
LogOutput::Console { message } => {
debug_assert!(
false,
"la biblioteca entregó un `Console`: alguien encendió el tty, y con \
tty el motivo de un fallo viaja como bytes de salida"
);
(Canal::Salida, message)
}
};
al_llegar(Trozo {
canal,
bytes: bytes.to_vec(),
});
}
Ok(())
};
conducir_la_orden(contenedor, entrega, drenaje).await?;
let estado = self
.docker
.inspect_exec(&creado.id)
.await
.map_err(|causa| Error::del_contenedor(contenedor, causa))?;
codigo_de_salida(&estado).ok_or_else(|| Error::SinCodigoDeSalida {
contenedor: contenedor.to_owned(),
})
}
}
const TROZO_DE_ENTRADA: usize = 8 * 1024;
async fn conducir_la_orden(
contenedor: &str,
entrega: impl Future<Output = Result<(), std::io::Error>>,
drenaje: impl Future<Output = Result<(), Error>>,
) -> Result<(), Error> {
let entrega = std::pin::pin!(entrega);
let drenaje = std::pin::pin!(drenaje);
match futures_util::future::select(entrega, drenaje).await {
futures_util::future::Either::Left((desenlace_de_la_entrada, drenaje)) => {
let del_drenaje = drenaje.await;
match desenlace_de_la_entrada {
Ok(()) => del_drenaje,
Err(causa) => Err(Error::EntradaNoEntregada {
contenedor: contenedor.to_owned(),
causa,
}),
}
}
futures_util::future::Either::Right((del_drenaje, _entrega)) => match del_drenaje {
Err(causa) => Err(causa),
Ok(()) => Err(Error::EntradaNoEntregada {
contenedor: contenedor.to_owned(),
causa: std::io::Error::new(
std::io::ErrorKind::BrokenPipe,
"la orden terminó antes de que el volcado llegara entero a su `stdin`: lo que \
recibió no es el volcado completo, y darla por buena restauraría una copia a \
medias",
),
}),
},
}
}
fn opciones_con_entrada(orden: &Orden) -> CreateExecOptions<&str> {
CreateExecOptions {
attach_stdin: Some(true),
attach_stdout: Some(true),
attach_stderr: Some(true),
tty: Some(false),
env: Some(orden.env.iter().map(String::as_str).collect()),
cmd: Some(orden.cmd.iter().map(String::as_str).collect()),
..Default::default()
}
}
fn codigo_de_salida(estado: &ExecInspectResponse) -> Option<i32> {
if estado.running == Some(true) {
return None;
}
estado
.exit_code
.and_then(|codigo| i32::try_from(codigo).ok())
}
impl std::fmt::Debug for Cliente {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Cliente")
.field("socket", &self.socket)
.field("timeout", &self.timeout)
.finish()
}
}
#[cfg(test)]
mod tests {
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::task::{Context, Poll};
use std::time::Duration;
use bollard::models::ExecInspectResponse;
use super::{Cliente, codigo_de_salida};
use crate::docker::{Canal, Error, Orden, Trozo};
use crate::test_support::{
ContenedorDePrueba, TIMEOUT_DEL_MOTOR, cliente_del_motor, directorio_de_prueba,
socket_del_motor,
};
const INACTIVIDAD_DE_PRUEBA: Duration = Duration::from_secs(60);
const INACTIVIDAD_CORTA: Duration = Duration::from_millis(500);
#[test]
fn conectar_con_un_socket_que_no_existe_falla_y_lo_nombra() {
let directorio = directorio_de_prueba();
let ruta = directorio.ruta().join("socket-que-no-existe.sock");
let ruta = ruta.to_string_lossy().into_owned();
let error = Cliente::conectar(&ruta, TIMEOUT_DEL_MOTOR)
.expect_err("un socket que no existe no puede dar un cliente");
let motivo = error.to_string();
assert!(
matches!(&error, Error::SocketInexistente { socket } if socket == &ruta),
"el error debe reconocer el socket que se pidió: {error:?}"
);
assert!(
motivo.contains(&ruta),
"el motivo no nombra el socket: {motivo}"
);
}
#[test]
fn conectar_acepta_las_dos_formas_del_socket() {
let directorio = directorio_de_prueba();
let ruta = directorio.ruta().join("socket-de-prueba");
std::fs::File::create(&ruta).expect("la ruta de la prueba debe poder crearse");
let ruta = ruta.to_string_lossy().into_owned();
let con_esquema = Cliente::conectar(&format!("unix://{ruta}"), TIMEOUT_DEL_MOTOR)
.expect("la forma con `unix://` es válida");
let pelada = Cliente::conectar(&ruta, TIMEOUT_DEL_MOTOR).expect("la ruta pelada es válida");
assert!(
format!("{con_esquema:?}").contains(&ruta),
"la forma con esquema no llegó a la misma ruta: {con_esquema:?}"
);
assert!(
format!("{pelada:?}").contains(&ruta),
"la forma pelada no llegó a la misma ruta: {pelada:?}"
);
}
#[test]
fn el_debug_del_cliente_no_vuelca_el_interior_de_la_biblioteca() {
let directorio = directorio_de_prueba();
let ruta = directorio.ruta().join("socket-de-prueba");
std::fs::File::create(&ruta).expect("la ruta de la prueba debe poder crearse");
let ruta = ruta.to_string_lossy().into_owned();
let cliente = Cliente::conectar(&ruta, TIMEOUT_DEL_MOTOR).expect("la ruta existe");
let nuestro = format!("{cliente:?}");
let del_motor = format!("{:?}", cliente.docker);
assert!(
nuestro.contains(&ruta),
"el Debug no nombra el socket: {nuestro}"
);
assert!(
nuestro.contains("timeout"),
"el Debug no publica el tiempo máximo: {nuestro}"
);
assert!(
nuestro.len() < del_motor.len(),
"el Debug del cliente publica el interior de la biblioteca ({nuestro}) en vez de \
quedarse con el socket y el tiempo máximo: el del motor mide {}",
del_motor.len()
);
}
#[test]
fn un_proceso_que_sigue_en_marcha_no_da_codigo_de_salida() {
let en_marcha = ExecInspectResponse {
running: Some(true),
exit_code: Some(0),
..Default::default()
};
assert_eq!(
codigo_de_salida(&en_marcha),
None,
"un proceso que sigue en marcha no puede dar un código de salida: el `0` que \
informa el motor no significa «salió bien», significa «todavía corre»"
);
let terminado_mal = ExecInspectResponse {
running: Some(false),
exit_code: Some(3),
..Default::default()
};
assert_eq!(codigo_de_salida(&terminado_mal), Some(3));
let sin_running = ExecInspectResponse {
running: None,
exit_code: Some(0),
..Default::default()
};
assert_eq!(codigo_de_salida(&sin_running), Some(0));
for sin_codigo in [
ExecInspectResponse {
running: Some(false),
exit_code: None,
..Default::default()
},
ExecInspectResponse {
running: Some(false),
exit_code: Some(i64::from(i32::MAX) + 1),
..Default::default()
},
] {
assert_eq!(
codigo_de_salida(&sin_codigo),
None,
"un código que no se puede decir es un código que no se puede decir: \
{sin_codigo:?}"
);
}
}
async fn ejecutar_recogiendo(
cliente: &Cliente,
contenedor: &str,
orden: &Orden,
) -> Result<(i32, Vec<Trozo>), Error> {
let mut trozos: Vec<Trozo> = Vec::new();
let codigo = {
let mut guardar = |trozo: Trozo| trozos.push(trozo);
cliente
.ejecutar(contenedor, orden, INACTIVIDAD_DE_PRUEBA, &mut guardar)
.await?
};
Ok((codigo, trozos))
}
fn texto_de(trozos: &[Trozo], canal: Canal) -> String {
let bytes: Vec<u8> = trozos
.iter()
.filter(|trozo| trozo.canal == canal)
.flat_map(|trozo| trozo.bytes.iter().copied())
.collect();
String::from_utf8_lossy(&bytes).into_owned()
}
fn orden_de_shell(guion: &str) -> Orden {
Orden {
cmd: vec!["sh".to_owned(), "-c".to_owned(), guion.to_owned()],
env: Vec::new(),
}
}
#[tokio::test]
#[ignore = "requiere un motor de contenedores, y el CI no levanta ninguno: se ejecuta con cargo test -- --ignored"]
async fn un_echo_entrega_su_salida_y_el_codigo_es_cero() {
let cliente = cliente_del_motor().expect("hace falta un socket de Docker o Podman");
let contenedor =
ContenedorDePrueba::nuevo("un_echo_entrega_su_salida_y_el_codigo_es_cero").await;
let orden = Orden {
cmd: vec!["echo".to_owned(), "hola".to_owned()],
env: Vec::new(),
};
let (codigo, trozos) = ejecutar_recogiendo(&cliente, contenedor.nombre(), &orden)
.await
.expect("el `echo` debe poder ejecutarse");
assert_eq!(codigo, 0, "el `echo` debe salir con cero");
assert_eq!(
texto_de(&trozos, Canal::Salida),
"hola\n",
"la salida no llegó entera: {trozos:?}"
);
assert_eq!(
texto_de(&trozos, Canal::Error),
"",
"no puede haber nada por el canal de error: {trozos:?}"
);
contenedor.retirar().await;
}
#[tokio::test]
#[ignore = "requiere un motor de contenedores, y el CI no levanta ninguno: se ejecuta con cargo test -- --ignored"]
async fn un_comando_que_falla_entrega_su_motivo_por_el_canal_de_error() {
let cliente = cliente_del_motor().expect("hace falta un socket de Docker o Podman");
let contenedor = ContenedorDePrueba::nuevo(
"un_comando_que_falla_entrega_su_motivo_por_el_canal_de_error",
)
.await;
let orden = orden_de_shell("echo el-motivo-del-fallo >&2; exit 3");
let (codigo, trozos) = ejecutar_recogiendo(&cliente, contenedor.nombre(), &orden)
.await
.expect("un comando que sale con 3 no es un fallo del transporte");
assert_eq!(codigo, 3, "el código de salida no es el del comando");
assert!(
texto_de(&trozos, Canal::Error).contains("el-motivo-del-fallo"),
"el motivo no llegó por el canal de error: {trozos:?}"
);
assert_eq!(
texto_de(&trozos, Canal::Salida),
"",
"el canal de salida no puede traer el motivo: {trozos:?}"
);
contenedor.retirar().await;
}
#[tokio::test]
#[ignore = "requiere un motor de contenedores, y el CI no levanta ninguno: se ejecuta con cargo test -- --ignored"]
async fn un_binario_que_no_existe_da_127_y_el_motivo_por_el_canal_de_error() {
let cliente = cliente_del_motor().expect("hace falta un socket de Docker o Podman");
let contenedor = ContenedorDePrueba::nuevo(
"un_binario_que_no_existe_da_127_y_el_motivo_por_el_canal_de_error",
)
.await;
let orden = Orden {
cmd: vec!["mariadb-dump".to_owned(), "--version".to_owned()],
env: Vec::new(),
};
let (codigo, trozos) = ejecutar_recogiendo(&cliente, contenedor.nombre(), &orden)
.await
.expect("que el binario no exista no rompe el `exec`");
assert_eq!(codigo, 127, "un binario ausente sale con 127");
assert_eq!(
texto_de(&trozos, Canal::Salida),
"",
"un binario ausente no escribe nada en la salida: {trozos:?}"
);
assert!(
!texto_de(&trozos, Canal::Error).is_empty(),
"el motivo del contenedor se perdió: {trozos:?}"
);
contenedor.retirar().await;
}
#[tokio::test]
#[ignore = "requiere un motor de contenedores, y el CI no levanta ninguno: se ejecuta con cargo test -- --ignored"]
async fn un_contenedor_que_no_existe_falla_y_el_motivo_lo_nombra() {
let cliente = cliente_del_motor().expect("hace falta un socket de Docker o Podman");
let nombre = "kesto-prueba-contenedor-que-no-existe";
let orden = orden_de_shell("echo nunca-llega");
let error = cliente
.ejecutar(nombre, &orden, INACTIVIDAD_DE_PRUEBA, |_| {})
.await
.expect_err("un contenedor que no existe no puede ejecutar nada");
let motivo = error.to_string();
assert!(
motivo.contains(nombre),
"el motivo no nombra el contenedor: {motivo}"
);
assert!(
matches!(
&error,
Error::MotorContesta {
status_code: 404,
..
}
),
"el contenedor ausente debe llegar como respuesta del motor: {error:?}"
);
}
#[tokio::test]
#[ignore = "requiere un motor de contenedores, y el CI no levanta ninguno: se ejecuta con cargo test -- --ignored"]
async fn las_variables_de_entorno_de_la_orden_llegan_al_proceso() {
let cliente = cliente_del_motor().expect("hace falta un socket de Docker o Podman");
let contenedor =
ContenedorDePrueba::nuevo("las_variables_de_entorno_de_la_orden_llegan_al_proceso")
.await;
let orden = Orden {
cmd: vec![
"sh".to_owned(),
"-c".to_owned(),
"echo PWD=[$MYSQL_PWD]".to_owned(),
],
env: vec!["MYSQL_PWD=secreto-de-prueba".to_owned()],
};
let (codigo, trozos) = ejecutar_recogiendo(&cliente, contenedor.nombre(), &orden)
.await
.expect("el `sh` debe poder ejecutarse");
assert_eq!(codigo, 0, "el `sh` debe salir con cero");
assert!(
texto_de(&trozos, Canal::Salida).contains("PWD=[secreto-de-prueba]"),
"la variable de entorno no llegó al proceso: {trozos:?}"
);
contenedor.retirar().await;
}
#[tokio::test]
#[ignore = "requiere un motor de contenedores, y el CI no levanta ninguno: se ejecuta con cargo test -- --ignored"]
async fn un_volumen_grande_llega_entero() {
const BYTES_ESCRITOS: usize = 4 * 1024 * 1024;
let cliente = cliente_del_motor().expect("hace falta un socket de Docker o Podman");
let contenedor = ContenedorDePrueba::nuevo("un_volumen_grande_llega_entero").await;
let orden = orden_de_shell(&format!("yes a | head -c {BYTES_ESCRITOS}"));
let (codigo, trozos) = ejecutar_recogiendo(&cliente, contenedor.nombre(), &orden)
.await
.expect("un `stdout` grande no es un fallo del transporte");
let recibidos: usize = trozos
.iter()
.filter(|trozo| trozo.canal == Canal::Salida)
.map(|trozo| trozo.bytes.len())
.sum();
assert_eq!(codigo, 0, "el comando debe salir con cero");
assert_eq!(
recibidos, BYTES_ESCRITOS,
"los bytes no cuadran: se perdieron o se duplicaron trozos"
);
assert!(
trozos
.iter()
.filter(|trozo| trozo.canal == Canal::Salida)
.all(|trozo| trozo
.bytes
.iter()
.all(|byte| *byte == b'a' || *byte == b'\n')),
"el contenido llegó alterado"
);
contenedor.retirar().await;
}
#[test]
#[ignore = "requiere un motor de contenedores, y el CI no levanta ninguno: se ejecuta con cargo test -- --ignored"]
fn el_exec_se_conduce_en_el_runtime_del_hilo_que_lo_lanza() {
const PRUEBA: &str = "el_exec_se_conduce_en_el_runtime_del_hilo_que_lo_lanza";
let socket = socket_del_motor().expect("hace falta un socket de Docker o Podman");
let (resultado, nombre) = std::thread::spawn(move || {
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
.expect("el runtime del hilo debe poder construirse");
runtime.block_on(async move {
let contenedor = ContenedorDePrueba::nuevo(PRUEBA).await;
let nombre = contenedor.nombre().to_owned();
let cliente =
Cliente::conectar(&socket, TIMEOUT_DEL_MOTOR).expect("el socket existe");
let orden = Orden {
cmd: vec!["echo".to_owned(), "desde-el-hilo".to_owned()],
env: Vec::new(),
};
let resultado = ejecutar_recogiendo(&cliente, contenedor.nombre(), &orden).await;
contenedor.retirar().await;
(resultado, nombre)
})
})
.join()
.expect("el hilo que conduce el `exec` no puede entrar en pánico");
let (codigo, trozos) =
resultado.expect("el `exec` debe completarse con el runtime del hilo vivo");
assert_eq!(codigo, 0, "el `echo` de {nombre} debe salir con cero");
assert_eq!(
texto_de(&trozos, Canal::Salida),
"desde-el-hilo\n",
"la salida no llegó entera desde el runtime del hilo: {trozos:?}"
);
}
#[test]
fn un_tiempo_maximo_menor_de_un_segundo_no_queda_en_cero() {
let directorio = directorio_de_prueba();
let ruta = directorio.ruta().join("socket-de-prueba");
std::fs::File::create(&ruta).expect("la ruta de la prueba debe poder crearse");
let ruta = ruta.to_string_lossy().into_owned();
let cliente = Cliente::conectar(&ruta, Duration::from_millis(500)).expect("la ruta existe");
assert!(
format!("{cliente:?}").contains("timeout: 1s"),
"el tiempo máximo quedó por debajo de un segundo: {cliente:?}"
);
}
#[tokio::test]
#[ignore = "requiere un motor de contenedores, y el CI no levanta ninguno: se ejecuta con cargo test -- --ignored"]
async fn los_dos_canales_de_una_misma_ejecucion_llegan_completos_y_en_su_orden() {
let cliente = cliente_del_motor().expect("hace falta un socket de Docker o Podman");
let contenedor = ContenedorDePrueba::nuevo(
"los_dos_canales_de_una_misma_ejecucion_llegan_completos_y_en_su_orden",
)
.await;
let orden = orden_de_shell("echo uno; echo dos >&2; echo tres; echo cuatro >&2");
let (codigo, trozos) = ejecutar_recogiendo(&cliente, contenedor.nombre(), &orden)
.await
.expect("alternar los dos canales no es un fallo del transporte");
assert_eq!(codigo, 0, "el guion debe salir con cero");
assert!(
!trozos.is_empty(),
"una ejecución que escribe por los dos canales no puede no entregar nada"
);
assert_eq!(
texto_de(&trozos, Canal::Salida),
"uno\ntres\n",
"la salida no llegó completa y en orden por su canal: {trozos:?}"
);
assert_eq!(
texto_de(&trozos, Canal::Error),
"dos\ncuatro\n",
"el error no llegó completo y en orden por su canal: {trozos:?}"
);
assert!(
trozos.iter().any(|trozo| trozo.canal == Canal::Salida)
&& trozos.iter().any(|trozo| trozo.canal == Canal::Error),
"faltó uno de los dos canales: {trozos:?}"
);
contenedor.retirar().await;
}
#[tokio::test]
#[ignore = "requiere un motor de contenedores, y el CI no levanta ninguno: se ejecuta con cargo test -- --ignored"]
async fn una_base_vacia_no_se_lee_como_fallo_ni_llama_al_callback() {
let cliente = cliente_del_motor().expect("hace falta un socket de Docker o Podman");
let contenedor =
ContenedorDePrueba::nuevo("una_base_vacia_no_se_lee_como_fallo_ni_llama_al_callback")
.await;
let orden = orden_de_shell("true");
let mut llamadas = 0usize;
let mut del_canal_de_error = 0usize;
let codigo = {
let mut contar = |trozo: Trozo| {
llamadas += 1;
if trozo.canal == Canal::Error {
del_canal_de_error += 1;
}
};
cliente
.ejecutar(
contenedor.nombre(),
&orden,
INACTIVIDAD_DE_PRUEBA,
&mut contar,
)
.await
.expect("una base vacía no es un fallo de extracción")
};
assert_eq!(codigo, 0, "una extracción vacía y correcta sale con cero");
assert_eq!(
llamadas, 0,
"el callback se llamó {llamadas} veces con una extracción que no escribió nada"
);
assert_eq!(
del_canal_de_error, 0,
"no puede haber nada por el canal de error: el comando salió con cero"
);
contenedor.retirar().await;
}
#[tokio::test]
#[ignore = "requiere un motor de contenedores, y el CI no levanta ninguno: se ejecuta con cargo test -- --ignored"]
async fn una_orden_que_se_calla_mas_que_el_plazo_acaba_en_extraccion_estancada() {
let cliente = cliente_del_motor().expect("hace falta un socket de Docker o Podman");
let contenedor = ContenedorDePrueba::nuevo(
"una_orden_que_se_calla_mas_que_el_plazo_acaba_en_extraccion_estancada",
)
.await;
let orden = orden_de_shell("sleep 600");
let error = cliente
.ejecutar(contenedor.nombre(), &orden, INACTIVIDAD_CORTA, |_| {})
.await
.expect_err("una orden que se calla más que el plazo no puede darse por buena");
let motivo = error.to_string();
assert!(
matches!(
&error,
Error::ExtraccionEstancada { contenedor: cual, inactividad }
if cual == contenedor.nombre() && *inactividad == INACTIVIDAD_CORTA
),
"el vencimiento no llegó como extracción estancada con su plazo: {error:?}"
);
assert!(
motivo.contains(contenedor.nombre()),
"el motivo no nombra el contenedor: {motivo}"
);
assert!(
motivo.contains("colgada"),
"el motivo no dice qué puede estar pasando: {motivo}"
);
contenedor.retirar().await;
}
#[tokio::test]
async fn comprobar_falla_si_al_otro_lado_no_hay_motor() {
let directorio = directorio_de_prueba();
let ruta = directorio.ruta().join("fichero-que-no-es-un-socket");
std::fs::File::create(&ruta).expect("la ruta de la prueba debe poder crearse");
let ruta = ruta.to_string_lossy().into_owned();
let cliente = Cliente::conectar(&ruta, TIMEOUT_DEL_MOTOR)
.expect("la ruta existe, así que el cliente se construye");
let error = cliente
.comprobar()
.await
.expect_err("un fichero normal no contesta a un `ping`");
let motivo = error.to_string();
assert!(
matches!(&error, Error::MotorNoDisponible { socket, .. } if socket == &ruta),
"el fallo del `ping` no se reconoció como motor no disponible: {error:?}"
);
assert!(
motivo.contains(&ruta),
"el motivo no nombra el socket: {motivo}"
);
}
#[tokio::test]
#[ignore = "requiere un motor de contenedores, y el CI no levanta ninguno: se ejecuta con cargo test -- --ignored"]
async fn comprobar_dice_que_si_cuando_el_motor_contesta() {
let cliente = cliente_del_motor().expect("hace falta un socket de Docker o Podman");
cliente
.comprobar()
.await
.expect("un motor vivo contesta al `ping`");
}
#[test]
fn la_orden_de_restauracion_pide_el_stdin_y_los_dos_canales_de_salida() {
let orden = Orden {
cmd: vec!["psql".to_owned(), "-U".to_owned(), "operador".to_owned()],
env: vec!["PGPASSWORD=secreta".to_owned()],
};
let opciones = super::opciones_con_entrada(&orden);
assert_eq!(
opciones.attach_stdin,
Some(true),
"la restauración sin `attach_stdin` no tiene por dónde recibir el volcado"
);
assert_eq!(
opciones.attach_stdout,
Some(true),
"sin `attach_stdout` no hay a qué contestar el motor"
);
assert_eq!(
opciones.attach_stderr,
Some(true),
"sin `attach_stderr` se pierde el motivo de un fallo del cliente"
);
assert_eq!(
opciones.tty,
Some(false),
"con tty la separación entre `stdout` y `stderr` se pierde"
);
assert_eq!(
opciones.cmd,
Some(vec!["psql", "-U", "operador"]),
"el comando no es el de la orden"
);
assert_eq!(
opciones.env,
Some(vec!["PGPASSWORD=secreta"]),
"el entorno no es el de la orden"
);
}
const ESPERA_DE_LA_UNION: Duration = Duration::from_secs(5);
async fn la_union_con_plazo<F: Future>(union: F) -> F::Output {
tokio::time::timeout(ESPERA_DE_LA_UNION, union)
.await
.expect(
"la unión no concluyó dentro del plazo: una de las dos mitades sobrevivió a la otra",
)
}
struct EntregaQueNuncaTermina {
soltada: Arc<AtomicBool>,
}
impl Future for EntregaQueNuncaTermina {
type Output = Result<(), std::io::Error>;
fn poll(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Self::Output> {
Poll::Pending
}
}
impl Drop for EntregaQueNuncaTermina {
fn drop(&mut self) {
self.soltada.store(true, Ordering::SeqCst);
}
}
#[tokio::test]
async fn el_drenaje_que_termina_no_deja_colgada_una_entrega_que_nunca_avanza() {
const INACTIVIDAD: Duration = Duration::from_millis(500);
let soltada = Arc::new(AtomicBool::new(false));
let entrega = EntregaQueNuncaTermina {
soltada: Arc::clone(&soltada),
};
let desenlace = la_union_con_plazo(super::conducir_la_orden(
"contenedor-de-la-base",
entrega,
async move {
Err(Error::RestauracionEstancada {
contenedor: "contenedor-de-la-base".to_owned(),
inactividad: INACTIVIDAD,
})
},
))
.await;
assert!(
matches!(desenlace, Err(Error::RestauracionEstancada { .. })),
"el drenaje venció su plazo y la unión no devolvió su desenlace: {desenlace:?}"
);
assert!(
soltada.load(Ordering::SeqCst),
"la entrega siguió viva después de terminar el drenaje: el `stdin` y el volcado se \
quedan retenidos, y el hilo que volcaba, bloqueado"
);
}
#[tokio::test]
async fn una_orden_que_termina_antes_de_recibir_el_volcado_no_se_da_por_buena() {
let soltada = Arc::new(AtomicBool::new(false));
let entrega = EntregaQueNuncaTermina {
soltada: Arc::clone(&soltada),
};
let desenlace = la_union_con_plazo(super::conducir_la_orden(
"contenedor-de-la-base",
entrega,
async { Ok(()) },
))
.await;
assert!(
matches!(desenlace, Err(Error::EntradaNoEntregada { .. })),
"el drenaje cerró limpio con el volcado a medias y la unión lo dio por bueno: \
{desenlace:?}"
);
assert!(
soltada.load(Ordering::SeqCst),
"la entrega siguió viva después de terminar el drenaje"
);
}
#[tokio::test]
async fn el_fallo_de_la_entrada_manda_sobre_el_cierre_bueno_del_drenaje() {
const MOTIVO: &str = "la instantánea no está en el repositorio";
let drenada = Arc::new(AtomicBool::new(false));
let marca = Arc::clone(&drenada);
let desenlace = la_union_con_plazo(super::conducir_la_orden(
"contenedor-de-la-base",
async move {
Err(std::io::Error::new(
std::io::ErrorKind::UnexpectedEof,
MOTIVO,
))
},
async move {
tokio::task::yield_now().await;
marca.store(true, Ordering::SeqCst);
Ok(())
},
))
.await;
match desenlace {
Err(Error::EntradaNoEntregada { causa, .. }) => assert!(
causa.to_string().contains(MOTIVO),
"el error de la entrada no lleva el motivo del repositorio: {causa}"
),
otro => panic!(
"con el volcado a medias, el desenlace tiene que ser el de la entrada: {otro:?}"
),
}
assert!(
drenada.load(Ordering::SeqCst),
"el drenaje se canceló al fallar la entrada: el motivo del contenedor se pierde"
);
}
#[tokio::test]
async fn sin_fallo_la_union_devuelve_el_desenlace_del_drenaje() {
let desenlace = la_union_con_plazo(super::conducir_la_orden(
"contenedor-de-la-base",
async { Ok(()) },
async {
tokio::task::yield_now().await;
Ok(())
},
))
.await;
assert!(
desenlace.is_ok(),
"con las dos mitades en bien, la unión no devolvió un bien: {desenlace:?}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "requiere un motor de contenedores, y el CI no levanta ninguno: se ejecuta con cargo test -- --ignored"]
async fn el_volcado_llega_entero_por_el_stdin_y_se_cierra_al_terminar() {
const BYTES: usize = 300_000;
let cliente = cliente_del_motor().expect("hace falta un socket de Docker o Podman");
let contenedor = ContenedorDePrueba::nuevo(
"el_volcado_llega_entero_por_el_stdin_y_se_cierra_al_terminar",
)
.await;
let orden = orden_de_shell("wc -c");
let contenido = vec![b'k'; BYTES];
let mut salida = Vec::new();
let codigo = cliente
.ejecutar_con_entrada(
contenedor.nombre(),
&orden,
INACTIVIDAD_DE_PRUEBA,
&contenido[..],
|trozo: Trozo| {
if trozo.canal == Canal::Salida {
salida.extend_from_slice(&trozo.bytes);
}
},
)
.await
.expect("una orden que lee su entrada no puede fallar por el transporte");
assert_eq!(
codigo, 0,
"el cliente de la prueba tiene que salir con cero"
);
let contados: usize = String::from_utf8_lossy(&salida)
.trim()
.parse()
.expect("`wc -c` tiene que contestar un número");
assert_eq!(
contados, BYTES,
"el contenedor recibió {contados} bytes de los {BYTES} entregados: el volcado llegó \
truncado o se cerró el `stdin` antes de tiempo"
);
contenedor.retirar().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "requiere un motor de contenedores, y el CI no levanta ninguno: se ejecuta con cargo test -- --ignored"]
async fn un_cliente_que_no_lee_su_stdin_acaba_en_el_plazo_y_no_cuelga_la_orden() {
const BYTES: usize = 4 * 1024 * 1024;
const ESPERA_DE_LA_PRUEBA: Duration = Duration::from_secs(30);
let cliente = cliente_del_motor().expect("hace falta un socket de Docker o Podman");
let contenedor = ContenedorDePrueba::nuevo(
"un_cliente_que_no_lee_su_stdin_acaba_en_el_plazo_y_no_cuelga_la_orden",
)
.await;
let orden = orden_de_shell("sleep 60");
let contenido = vec![b'k'; BYTES];
let desenlace = tokio::time::timeout(
ESPERA_DE_LA_PRUEBA,
cliente.ejecutar_con_entrada(
contenedor.nombre(),
&orden,
INACTIVIDAD_CORTA,
&contenido[..],
|_trozo: Trozo| {},
),
)
.await
.expect(
"la orden no concluyó dentro del plazo: la entrega sobrevivió al drenaje, y con ella \
se quedan el hilo que volcaba y el destino sin liberar",
);
assert!(
matches!(desenlace, Err(Error::RestauracionEstancada { .. })),
"con el cliente callado y su `stdin` sin drenar, el desenlace tiene que ser el del \
drenaje: {desenlace:?}"
);
contenedor.retirar().await;
}
}