use std::path::PathBuf;
use std::time::Duration;
use thiserror::Error;
use tokio::io::AsyncRead;
use crate::db::entities::{ObjetivoDeRestauracion, TipoDeTarget};
use crate::docker::{Canal, Cliente, Orden, Trozo};
use crate::engine::extraccion::{
acumular_el_motivo, entorno_del_exec, lo_que_dijo_el_contenedor, motivo_para_guardar,
nombre_del_tipo, recortar,
};
use super::ErrorDelMotor;
#[derive(Debug)]
pub enum CaminoDeRestauracion {
PorContenedor {
contenedor: String,
ordenes: Vec<Orden>,
},
PorDirectorio {
ruta: PathBuf,
},
}
pub fn comando(
objetivo: &ObjetivoDeRestauracion,
) -> Result<CaminoDeRestauracion, ErrorDeRestauracion> {
match objetivo.target_type {
TipoDeTarget::Directory => {
let ruta = exigido(objetivo, "host_path", objetivo.host_path.as_deref())?;
Ok(CaminoDeRestauracion::PorDirectorio {
ruta: PathBuf::from(ruta),
})
}
TipoDeTarget::Mariadb => {
let (contenedor, usuario, base) = datos_de_una_base(objetivo)?;
let mut cmd = vec!["mariadb".to_owned(), format!("-u{usuario}")];
if let Some(base) = base {
cmd.push(base.to_owned());
}
Ok(CaminoDeRestauracion::PorContenedor {
contenedor: contenedor.to_owned(),
ordenes: vec![Orden {
cmd,
env: entorno_del_exec(TipoDeTarget::Mariadb, objetivo.db_password.as_ref()),
}],
})
}
TipoDeTarget::Postgresql => {
let (contenedor, usuario, base) = datos_de_una_base(objetivo)?;
let cmd = vec![
"psql".to_owned(),
"-U".to_owned(),
usuario.to_owned(),
"-d".to_owned(),
base.unwrap_or("postgres").to_owned(),
];
Ok(CaminoDeRestauracion::PorContenedor {
contenedor: contenedor.to_owned(),
ordenes: vec![Orden {
cmd,
env: entorno_del_exec(TipoDeTarget::Postgresql, objetivo.db_password.as_ref()),
}],
})
}
TipoDeTarget::Sqlite => {
let contenedor = exigido(
objetivo,
"container_name",
objetivo.container_name.as_deref(),
)?;
let db_path = exigido(objetivo, "db_path", objetivo.db_path.as_deref())?;
let db_path = sin_ser_bandera(objetivo, "db_path", db_path)?;
let retirada = Orden {
cmd: vec![
"rm".to_owned(),
"-f".to_owned(),
db_path.to_owned(),
format!("{db_path}-wal"),
format!("{db_path}-shm"),
],
env: Vec::new(),
};
let reimportacion = Orden {
cmd: vec!["sqlite3".to_owned(), db_path.to_owned()],
env: Vec::new(),
};
Ok(CaminoDeRestauracion::PorContenedor {
contenedor: contenedor.to_owned(),
ordenes: vec![retirada, reimportacion],
})
}
}
}
pub fn destino_del_objetivo(
objetivo: &ObjetivoDeRestauracion,
) -> Result<String, ErrorDeRestauracion> {
match objetivo.target_type {
TipoDeTarget::Directory => {
Ok(exigido(objetivo, "host_path", objetivo.host_path.as_deref())?.to_owned())
}
TipoDeTarget::Mariadb | TipoDeTarget::Postgresql => {
let (contenedor, _usuario, base) = datos_de_una_base(objetivo)?;
Ok(clave_de_destino(objetivo.target_type, contenedor, base))
}
TipoDeTarget::Sqlite => {
let contenedor = exigido(
objetivo,
"container_name",
objetivo.container_name.as_deref(),
)?;
let db_path = exigido(objetivo, "db_path", objetivo.db_path.as_deref())?;
Ok(clave_de_destino(
objetivo.target_type,
contenedor,
Some(db_path),
))
}
}
}
fn clave_de_destino(tipo: TipoDeTarget, contenedor: &str, base: Option<&str>) -> String {
format!(
"{}:{contenedor}:{}",
nombre_del_tipo(tipo),
base.unwrap_or_default()
)
}
fn datos_de_una_base(
objetivo: &ObjetivoDeRestauracion,
) -> Result<(&str, &str, Option<&str>), ErrorDeRestauracion> {
let contenedor = exigido(
objetivo,
"container_name",
objetivo.container_name.as_deref(),
)?;
let usuario = exigido(objetivo, "db_user", objetivo.db_user.as_deref())?;
let base = campo_opcional(objetivo, "db_name", objetivo.db_name.as_deref())?;
let usuario = sin_ser_bandera(objetivo, "db_user", usuario)?;
let base = base
.map(|base| sin_ser_bandera(objetivo, "db_name", base))
.transpose()?;
Ok((contenedor, usuario, base))
}
fn sin_ser_bandera<'a>(
objetivo: &ObjetivoDeRestauracion,
campo: &'static str,
valor: &'a str,
) -> Result<&'a str, ErrorDeRestauracion> {
if valor.starts_with('-') {
return Err(ErrorDeRestauracion::ValorQuePareceBandera {
tipo: nombre_del_tipo(objetivo.target_type),
campo,
});
}
Ok(valor)
}
fn exigido<'a>(
objetivo: &ObjetivoDeRestauracion,
campo: &'static str,
valor: Option<&'a str>,
) -> Result<&'a str, ErrorDeRestauracion> {
let tipo = nombre_del_tipo(objetivo.target_type);
match valor {
None => Err(ErrorDeRestauracion::CampoQueFalta { tipo, campo }),
Some(valor) if valor.trim().is_empty() => {
Err(ErrorDeRestauracion::CampoEnBlanco { tipo, campo })
}
Some(valor) => Ok(valor),
}
}
fn campo_opcional<'a>(
objetivo: &ObjetivoDeRestauracion,
campo: &'static str,
valor: Option<&'a str>,
) -> Result<Option<&'a str>, ErrorDeRestauracion> {
match valor {
None => Ok(None),
Some(valor) if valor.trim().is_empty() => Err(ErrorDeRestauracion::CampoEnBlanco {
tipo: nombre_del_tipo(objetivo.target_type),
campo,
}),
Some(valor) => Ok(Some(valor)),
}
}
#[derive(Debug, Error)]
pub enum ErrorDeRestauracion {
#[error(
"a un objetivo de tipo `{tipo}` le falta `{campo}`: sin él no hay restauración que valga"
)]
CampoQueFalta {
tipo: &'static str,
campo: &'static str,
},
#[error(
"el `{campo}` de un objetivo de tipo `{tipo}` está informado y en blanco: no nombra nada"
)]
CampoEnBlanco {
tipo: &'static str,
campo: &'static str,
},
#[error(
"el `{campo}` de un objetivo de tipo `{tipo}` empieza por `-`: la línea de comandos lo \
leería como una bandera y la restauración cambiaría sin que nada lo note"
)]
ValorQuePareceBandera {
tipo: &'static str,
campo: &'static str,
},
}
use std::pin::Pin;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::task::{Context, Poll};
use tokio::io::ReadBuf;
use tokio::sync::mpsc;
pub struct TunelDeRestauracion {
pub escritor: EscritorDelVolcado,
pub lector: LectorDelVolcado,
}
pub fn tunel_de_restauracion(nombre: impl Into<String>, capacidad: usize) -> TunelDeRestauracion {
let nombre = nombre.into();
let (emisor, receptor) = mpsc::channel(capacidad);
let terminado_bien = Arc::new(AtomicBool::new(false));
let fallo = Arc::new(Mutex::new(None));
TunelDeRestauracion {
escritor: EscritorDelVolcado {
nombre: nombre.clone(),
emisor,
terminado_bien: Arc::clone(&terminado_bien),
fallo: Arc::clone(&fallo),
},
lector: LectorDelVolcado {
nombre,
receptor,
sobrante: Vec::new(),
consumido: 0,
terminado_bien,
fallo,
},
}
}
pub struct EscritorDelVolcado {
nombre: String,
emisor: mpsc::Sender<Vec<u8>>,
terminado_bien: Arc<AtomicBool>,
fallo: Arc<Mutex<Option<String>>>,
}
impl std::fmt::Debug for EscritorDelVolcado {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("EscritorDelVolcado")
.field("nombre", &self.nombre)
.finish_non_exhaustive()
}
}
impl std::io::Write for EscritorDelVolcado {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
if buf.is_empty() {
return Ok(0);
}
let bytes = buf.len();
let trozo = buf.to_vec();
self.emisor.blocking_send(trozo).map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::BrokenPipe,
format!(
"el lector del volcado `{}` soltó el canal: ya nadie consume el volcado, así \
que el repositorio deja de volcarlo",
self.nombre
),
)
})?;
Ok(bytes)
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl EscritorDelVolcado {
pub fn terminar_bien(self) {
self.terminado_bien.store(true, Ordering::Release);
}
pub fn fallar(self, motivo: impl Into<String>) {
match self.fallo.lock() {
Ok(mut ranura) => *ranura = Some(motivo.into()),
Err(veneno) => *veneno.into_inner() = Some(motivo.into()),
}
}
}
pub struct LectorDelVolcado {
nombre: String,
receptor: mpsc::Receiver<Vec<u8>>,
sobrante: Vec<u8>,
consumido: usize,
terminado_bien: Arc<AtomicBool>,
fallo: Arc<Mutex<Option<String>>>,
}
impl LectorDelVolcado {
fn volcado_a_medias(&self) -> std::io::Error {
let motivo = match self.fallo.lock() {
Ok(ranura) => ranura.clone(),
Err(veneno) => veneno.into_inner().clone(),
};
let mensaje = match motivo {
Some(motivo) => format!(
"el volcado `{}` no se pudo leer del repositorio: {motivo}",
self.nombre
),
None => format!(
"el escritor del volcado `{}` soltó el canal sin declarar el final: el volcado \
puede estar truncado, y restaurarlo daría por buena una base a medias",
self.nombre
),
};
std::io::Error::new(std::io::ErrorKind::UnexpectedEof, mensaje)
}
}
impl std::fmt::Debug for LectorDelVolcado {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LectorDelVolcado")
.field("nombre", &self.nombre)
.finish_non_exhaustive()
}
}
impl AsyncRead for LectorDelVolcado {
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(()));
}
if this.consumido < this.sobrante.len() {
let pendientes = &this.sobrante[this.consumido..];
let a_entregar = pendientes.len().min(buf.remaining());
buf.put_slice(&pendientes[..a_entregar]);
this.consumido += a_entregar;
if this.consumido == this.sobrante.len() {
this.sobrante.clear();
this.consumido = 0;
}
return Poll::Ready(Ok(()));
}
match this.receptor.poll_recv(cx) {
Poll::Ready(Some(datos)) => {
let a_entregar = datos.len().min(buf.remaining());
buf.put_slice(&datos[..a_entregar]);
if a_entregar < datos.len() {
this.sobrante = datos;
this.consumido = a_entregar;
}
Poll::Ready(Ok(()))
}
Poll::Pending => Poll::Pending,
Poll::Ready(None) => {
if this.terminado_bien.load(Ordering::Acquire) {
Poll::Ready(Ok(()))
} else {
Poll::Ready(Err(this.volcado_a_medias()))
}
}
}
}
}
pub async fn ejecutar_las_ordenes(
cliente: &Cliente,
contenedor: &str,
ordenes: &[Orden],
volcado: LectorDelVolcado,
inactividad_maxima: Duration,
) -> Result<i32, ErrorDelMotor> {
let Some((reimportacion, retiradas)) = ordenes.split_last() else {
return Ok(0);
};
for orden in retiradas {
ejecutar_una(
cliente,
contenedor,
orden,
tokio::io::empty(),
inactividad_maxima,
)
.await?;
}
ejecutar_una(
cliente,
contenedor,
reimportacion,
volcado,
inactividad_maxima,
)
.await
}
async fn ejecutar_una(
cliente: &Cliente,
contenedor: &str,
orden: &Orden,
entrada: impl AsyncRead + Unpin,
inactividad_maxima: Duration,
) -> Result<i32, ErrorDelMotor> {
let mut crudo: Vec<u8> = Vec::new();
let codigo = {
let mut al_llegar = |trozo: Trozo| match trozo.canal {
Canal::Error => acumular_el_motivo(&mut crudo, &trozo.bytes),
Canal::Salida => {}
};
cliente
.ejecutar_con_entrada(
contenedor,
orden,
inactividad_maxima,
entrada,
&mut al_llegar,
)
.await
};
let dijo = motivo_para_guardar(crudo, orden);
match codigo {
Ok(0) => Ok(0),
Ok(codigo) => Err(ErrorDelMotor::Restauracion {
motivo: recortar(&format!(
"la orden de restauración en `{contenedor}` salió con el código {codigo}{}",
lo_que_dijo_el_contenedor(&dijo)
)),
}),
Err(causa) => Err(ErrorDelMotor::Restauracion {
motivo: recortar(&format!("{causa}{}", lo_que_dijo_el_contenedor(&dijo))),
}),
}
}
#[cfg(test)]
mod tests {
use std::io::Write as _;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use tokio::io::AsyncReadExt;
use super::{
CaminoDeRestauracion, ErrorDeRestauracion, EscritorDelVolcado, TunelDeRestauracion,
comando, ejecutar_las_ordenes, tunel_de_restauracion,
};
use crate::db::entities::{ObjetivoDeRestauracion, TipoDeTarget};
use crate::docker::{Canal, Cliente, Orden, Trozo};
use crate::engine::ErrorDelMotor;
use crate::engine::extraccion::nombre_del_tipo;
use crate::secret::SecretString;
use crate::test_support::{ContenedorDePrueba, cliente_del_motor};
const CONTRASENA: &str = "Zq7-clave-de-base-distinguible";
fn secreto(valor: &str) -> SecretString {
SecretString::new(valor.to_owned())
}
fn argv(argumentos: &[&str]) -> Vec<String> {
argumentos
.iter()
.map(|argumento| (*argumento).to_owned())
.collect()
}
fn objetivo_mariadb(db_name: Option<&str>) -> ObjetivoDeRestauracion {
ObjetivoDeRestauracion {
job_id: uuid::Uuid::new_v4(),
target_id: uuid::Uuid::new_v4(),
target_type: TipoDeTarget::Mariadb,
container_name: Some("contenedor-de-la-base".to_owned()),
db_user: Some("operador".to_owned()),
db_password: Some(secreto(CONTRASENA)),
db_name: db_name.map(ToOwned::to_owned),
host_path: None,
db_path: None,
target_name: "target-de-prueba".to_owned(),
}
}
fn objetivo_postgresql(db_name: Option<&str>) -> ObjetivoDeRestauracion {
ObjetivoDeRestauracion {
target_type: TipoDeTarget::Postgresql,
..objetivo_mariadb(db_name)
}
}
fn objetivo_directorio(host_path: Option<&str>) -> ObjetivoDeRestauracion {
ObjetivoDeRestauracion {
target_type: TipoDeTarget::Directory,
container_name: None,
db_user: None,
db_password: None,
db_name: None,
host_path: host_path.map(ToOwned::to_owned),
db_path: None,
..objetivo_mariadb(None)
}
}
fn objetivo_sqlite(db_path: Option<&str>) -> ObjetivoDeRestauracion {
ObjetivoDeRestauracion {
target_type: TipoDeTarget::Sqlite,
db_user: None,
db_password: None,
db_name: None,
db_path: db_path.map(ToOwned::to_owned),
..objetivo_mariadb(None)
}
}
fn por_contenedor(objetivo: &ObjetivoDeRestauracion) -> (String, Vec<Orden>) {
match comando(objetivo).expect("este camino tiene que construirse") {
CaminoDeRestauracion::PorContenedor {
contenedor,
ordenes,
} => (contenedor, ordenes),
CaminoDeRestauracion::PorDirectorio { ruta } => panic!(
"un objetivo de tipo `{:?}` no restaura escribiendo un directorio: {ruta:?}",
objetivo.target_type
),
}
}
#[test]
fn el_comando_de_mariadb_sin_base_no_nombra_ninguna() {
let (contenedor, ordenes) = por_contenedor(&objetivo_mariadb(None));
assert_eq!(contenedor, "contenedor-de-la-base");
assert_eq!(
ordenes.len(),
1,
"una base se reimporta con una sola orden: {ordenes:?}"
);
assert_eq!(
ordenes[0].cmd,
argv(&["mariadb", "-uoperador"]),
"el comando de un volcado global no es el de un `mariadb-dump --all-databases`: {:?}",
ordenes[0].cmd
);
}
#[test]
fn el_comando_de_mariadb_con_base_nombra_esa_base() {
let (_contenedor, ordenes) = por_contenedor(&objetivo_mariadb(Some("tienda")));
assert_eq!(
ordenes[0].cmd,
argv(&["mariadb", "-uoperador", "tienda"]),
"el comando no nombra la base pedida: {:?}",
ordenes[0].cmd
);
}
#[test]
fn el_comando_de_postgresql_sin_base_usa_la_base_postgres() {
let (_contenedor, ordenes) = por_contenedor(&objetivo_postgresql(None));
assert_eq!(
ordenes[0].cmd,
argv(&["psql", "-U", "operador", "-d", "postgres"]),
"el comando de un volcado global no conecta a `postgres`: {:?}",
ordenes[0].cmd
);
}
#[test]
fn el_comando_de_postgresql_con_base_nombra_esa_base() {
let (_contenedor, ordenes) = por_contenedor(&objetivo_postgresql(Some("almacen")));
assert_eq!(
ordenes[0].cmd,
argv(&["psql", "-U", "operador", "-d", "almacen"]),
"el comando no conexiona a la base pedida: {:?}",
ordenes[0].cmd
);
assert!(
!ordenes[0]
.cmd
.iter()
.any(|argumento| argumento == "postgres"),
"el comando de una base nombrada no puede caer en `postgres`: {:?}",
ordenes[0].cmd
);
}
#[test]
fn el_comando_de_un_sqlite_retira_el_fichero_antes_de_reimportar() {
let (contenedor, ordenes) =
por_contenedor(&objetivo_sqlite(Some("/var/lib/kesto/kesto.db")));
assert_eq!(contenedor, "contenedor-de-la-base");
assert_eq!(
ordenes.len(),
2,
"un sqlite se reconstruye en dos órdenes, y no en una: {ordenes:?}"
);
assert_eq!(
ordenes[0].cmd,
argv(&[
"rm",
"-f",
"/var/lib/kesto/kesto.db",
"/var/lib/kesto/kesto.db-wal",
"/var/lib/kesto/kesto.db-shm",
]),
"la retirada no borra el fichero vivo y sus dos hermanos: {:?}",
ordenes[0].cmd
);
assert_eq!(
ordenes[1].cmd,
argv(&["sqlite3", "/var/lib/kesto/kesto.db"]),
"la reimportación no es `sqlite3 <db_path>`: {:?}",
ordenes[1].cmd
);
}
#[test]
fn un_sqlite_no_usa_la_credencial_ni_el_nombre_de_base() {
let objetivo = ObjetivoDeRestauracion {
db_user: Some("operador".to_owned()),
db_password: Some(secreto(CONTRASENA)),
db_name: Some("tienda".to_owned()),
..objetivo_sqlite(Some("/var/lib/kesto/kesto.db"))
};
let (_contenedor, ordenes) = por_contenedor(&objetivo);
for orden in &ordenes {
assert!(
orden.env.is_empty(),
"un sqlite no tiene credencial de base, así que no puede llevar entorno: {:?}",
orden.env
);
for prohibido in ["operador", CONTRASENA, "tienda"] {
assert!(
!orden
.cmd
.iter()
.any(|argumento| argumento.contains(prohibido)),
"el comando de un sqlite lleva `{prohibido}`, que su tipo no usa: {:?}",
orden.cmd
);
}
}
}
#[test]
fn la_contrasena_viaja_en_el_entorno_y_nunca_en_la_linea_de_comandos() {
for (objetivo, variable, usuario_en_el_cmd) in [
(objetivo_mariadb(None), "MYSQL_PWD", "-uoperador"),
(objetivo_postgresql(None), "PGPASSWORD", "operador"),
] {
let (_contenedor, ordenes) = por_contenedor(&objetivo);
let orden = &ordenes[0];
assert!(
!orden
.cmd
.iter()
.any(|argumento| argumento.contains(CONTRASENA)),
"la contraseña está en la línea de comandos: {:?}",
orden.cmd
);
assert!(
orden
.cmd
.iter()
.any(|argumento| argumento == usuario_en_el_cmd),
"el comando tiene que llevar el usuario: {:?}",
orden.cmd
);
assert_eq!(
orden.env,
vec![format!("{variable}={CONTRASENA}")],
"la contraseña no viaja en `{variable}`: {:?}",
orden.env
);
let camino = comando(&objetivo).expect("el camino ya se construyó una vez");
assert!(
!format!("{camino:?}").contains(CONTRASENA),
"el Debug del camino publica la contraseña: {camino:?}"
);
}
}
#[test]
fn un_target_sin_contrasena_no_inventa_la_variable() {
for contrasena in [None, Some(secreto(""))] {
let objetivo = ObjetivoDeRestauracion {
db_password: contrasena,
..objetivo_mariadb(None)
};
let (_contenedor, ordenes) = por_contenedor(&objetivo);
assert!(
ordenes[0].env.is_empty(),
"un target sin contraseña no puede llevar entorno: {:?}",
ordenes[0].env
);
assert_eq!(
ordenes[0].cmd,
argv(&["mariadb", "-uoperador"]),
"sin contraseña el comando tiene que ser el mismo: {:?}",
ordenes[0].cmd
);
}
}
#[test]
fn un_sqlite_sin_db_path_utilizable_no_construye_ninguna_orden() {
for (db_path, en_blanco) in [(None, false), (Some(" "), true)] {
let error = comando(&objetivo_sqlite(db_path))
.expect_err("un sqlite sin una ruta utilizable no tiene órdenes que ejecutar");
match (&error, en_blanco) {
(ErrorDeRestauracion::CampoQueFalta { tipo, campo }, false) => {
assert_eq!(
*tipo, "sqlite",
"el motivo tiene que nombrar el tipo `sqlite`"
);
assert_eq!(
*campo, "db_path",
"el motivo tiene que nombrar el campo `db_path`"
);
}
(ErrorDeRestauracion::CampoEnBlanco { tipo, campo }, true) => {
assert_eq!(*tipo, "sqlite");
assert_eq!(*campo, "db_path");
}
(otro, _) => panic!(
"el rechazo de un `db_path` que no sirve tiene que ser del campo: {otro:?}"
),
}
assert!(
error.to_string().contains("db_path"),
"el motivo no nombra el campo que falta: {error}"
);
}
}
#[test]
fn un_target_de_base_sin_usuario_o_sin_contenedor_no_se_puede_restaurar() {
for tipo in [TipoDeTarget::Mariadb, TipoDeTarget::Postgresql] {
let sin_usuario = ObjetivoDeRestauracion {
db_user: None,
..objetivo_mariadb(None)
};
let sin_usuario = ObjetivoDeRestauracion {
target_type: tipo,
..sin_usuario
};
let error =
comando(&sin_usuario).expect_err("sin usuario no hay comando que construir");
assert!(
matches!(
&error,
ErrorDeRestauracion::CampoQueFalta {
campo: "db_user",
tipo: cual,
} if *cual == nombre_del_tipo(tipo)
),
"el fallo no nombra el campo que falta: {error:?}"
);
let sin_contenedor = ObjetivoDeRestauracion {
container_name: None,
..sin_usuario
};
let sin_contenedor = ObjetivoDeRestauracion {
db_user: Some("operador".to_owned()),
..sin_contenedor
};
let error = comando(&sin_contenedor)
.expect_err("sin contenedor no hay dónde ejecutar la orden");
assert!(
matches!(
&error,
ErrorDeRestauracion::CampoQueFalta {
campo: "container_name",
..
}
),
"el fallo no nombra el contenedor que falta: {error:?}"
);
}
}
#[test]
fn un_valor_que_parece_bandera_se_rechaza() {
let caso = |objetivo: ObjetivoDeRestauracion, campo: &'static str, valor: &str| {
let error =
comando(&objetivo).expect_err("un valor que parece una bandera no da un camino");
let mensaje = error.to_string();
assert!(
matches!(
&error,
ErrorDeRestauracion::ValorQuePareceBandera { campo: cual, .. } if *cual == campo
),
"el fallo tiene que nombrar el campo `{campo}`: {error:?}"
);
assert!(
mensaje.contains(campo),
"el motivo no nombra el campo `{campo}`: {mensaje}"
);
assert!(
!mensaje.contains(valor),
"el motivo publica el valor que se rechazó: {mensaje}"
);
assert!(
!mensaje.contains(CONTRASENA),
"el motivo publica la contraseña: {mensaje}"
);
};
caso(
ObjetivoDeRestauracion {
db_name: Some("-d".to_owned()),
..objetivo_postgresql(None)
},
"db_name",
"-d",
);
caso(
ObjetivoDeRestauracion {
db_user: Some("--user".to_owned()),
..objetivo_mariadb(None)
},
"db_user",
"--user",
);
caso(objetivo_sqlite(Some("--version")), "db_path", "--version");
}
#[test]
fn un_target_de_directorio_no_ejecuta_ningun_comando() {
let ruta_esperada = PathBuf::from("/backup_sources/var_www");
match comando(&objetivo_directorio(Some("/backup_sources/var_www")))
.expect("un directorio con su ruta da un camino")
{
CaminoDeRestauracion::PorDirectorio { ruta } => assert_eq!(ruta, ruta_esperada),
CaminoDeRestauracion::PorContenedor {
contenedor,
ordenes,
} => panic!(
"un directorio no pasa por Docker, y salió por el contenedor `{contenedor}` con \
las órdenes {ordenes:?}"
),
}
}
#[test]
fn un_target_de_directorio_sin_ruta_no_se_puede_restaurar() {
let error = comando(&objetivo_directorio(None))
.expect_err("sin ruta no hay destino donde restaurar");
assert!(
matches!(
&error,
ErrorDeRestauracion::CampoQueFalta {
campo: "host_path",
..
}
),
"el fallo no nombra la ruta que falta: {error:?}"
);
}
async fn esperar_a_que_entregue(contador: &AtomicUsize, objetivo: usize) -> usize {
let tope = tokio::time::Instant::now() + Duration::from_secs(10);
loop {
let visto = contador.load(Ordering::SeqCst);
if visto >= objetivo {
return visto;
}
assert!(
tokio::time::Instant::now() < tope,
"el escritor no llegó a {objetivo} trozos en diez segundos: el puente está atascado"
);
tokio::task::yield_now().await;
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn el_puente_entrega_todos_los_bytes_en_orden() {
const TAMANO_DEL_TROZO: usize = 7;
let esperado: Vec<u8> = (0..40_000u32).map(|n| (n % 251) as u8).collect();
let TunelDeRestauracion {
mut escritor,
mut lector,
} = tunel_de_restauracion("volcado-de-prueba.sql", 4);
let trozos: Vec<Vec<u8>> = esperado
.chunks(TAMANO_DEL_TROZO)
.map(<[u8]>::to_vec)
.collect();
let hilo = std::thread::spawn(move || {
for trozo in trozos {
escritor
.write_all(&trozo)
.expect("el lector consume, así que escribir no puede fallar");
}
escritor.terminar_bien();
});
let mut recibido = Vec::new();
lector
.read_to_end(&mut recibido)
.await
.expect("el volcado tiene que llegar entero");
hilo.join().expect("el hilo del volcado no se puede caer");
assert_eq!(
recibido, esperado,
"el puente no entregó los bytes del volcado en orden"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn el_puente_acota_la_memoria_con_la_capacidad_del_canal() {
const CAPACIDAD: usize = 2;
const TOTAL: usize = 5_000;
const TAMANO: usize = 1_024;
let TunelDeRestauracion {
mut escritor,
mut lector,
} = tunel_de_restauracion("volcado-grande.sql", CAPACIDAD);
let entregados = Arc::new(AtomicUsize::new(0));
let contador = Arc::clone(&entregados);
let hilo = std::thread::spawn(move || {
for _ in 0..TOTAL {
escritor
.write_all(&[b'x'; TAMANO])
.expect("el lector consume, así que escribir no puede fallar");
contador.fetch_add(1, Ordering::SeqCst);
}
escritor.terminar_bien();
});
let visto = esperar_a_que_entregue(&entregados, CAPACIDAD).await;
assert_eq!(
visto, CAPACIDAD,
"el escritor entregó {visto} trozos sin lector: el canal no acota nada"
);
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(
entregados.load(Ordering::SeqCst),
CAPACIDAD,
"el escritor siguió adelante sin lector: la memoria crece con el volcado"
);
let mut recibido = Vec::new();
lector
.read_to_end(&mut recibido)
.await
.expect("el volcado tiene que llegar entero");
hilo.join().expect("el hilo del volcado no se puede caer");
assert_eq!(
recibido.len(),
TOTAL * TAMANO,
"no llegaron todos los bytes del volcado"
);
assert_eq!(
entregados.load(Ordering::SeqCst),
TOTAL,
"el escritor no llegó a entregar el volcado entero tras drenar"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn un_volcado_que_falla_llega_al_lector_como_error() {
const PRIMER_TROZO: &[u8] = b"-- la mitad del volcado\n";
const MOTIVO: &str = "la instantánea no está en el repositorio";
let TunelDeRestauracion {
mut escritor,
mut lector,
} = tunel_de_restauracion("volcado-a-medias.sql", 4);
let hilo = std::thread::spawn(move || {
escritor
.write_all(PRIMER_TROZO)
.expect("el lector consume, así que escribir no puede fallar");
escritor.fallar(MOTIVO);
});
let mut primero = [0u8; 64];
let leidos = lector.read(&mut primero).await.expect("el trozo llega");
assert_eq!(&primero[..leidos], PRIMER_TROZO);
let error = lector
.read(&mut primero)
.await
.expect_err("un volcado que falló no puede terminar en fin de fichero");
assert_eq!(
error.kind(),
std::io::ErrorKind::UnexpectedEof,
"un volcado a medias no es un fin de fichero: {error}"
);
assert!(
error.to_string().contains(MOTIVO),
"el error no lleva el motivo del repositorio: {error}"
);
hilo.join().expect("el hilo del volcado no se puede caer");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn cerrar_sin_declarar_el_final_es_un_volcado_truncado() {
let TunelDeRestauracion {
mut escritor,
mut lector,
} = tunel_de_restauracion("volcado-truncado.sql", 4);
let hilo = std::thread::spawn(move || {
escritor
.write_all(b"-- y hasta aqui\n")
.expect("el lector consume, así que escribir no puede fallar");
});
const TROZO: &[u8] = b"-- y hasta aqui\n";
let mut primero = [0u8; TROZO.len()];
lector
.read_exact(&mut primero)
.await
.expect("el trozo llega antes del cierre");
let error = lector
.read(&mut primero)
.await
.expect_err("cerrar sin declarar el final no es un fin de fichero");
assert_eq!(
error.kind(),
std::io::ErrorKind::UnexpectedEof,
"un cierre sin final no es un fin de fichero: {error}"
);
assert!(
error.to_string().contains("truncado"),
"el error no dice que el volcado pueda estar truncado: {error}"
);
hilo.join().expect("el hilo del volcado no se puede caer");
}
#[test]
fn escribir_sin_lector_es_un_error_de_escritura() {
let tunel = tunel_de_restauracion("volcado-sin-lector.sql", 4);
drop(tunel.lector);
let mut escritor = tunel.escritor;
let error = escritor
.write_all(b"un trozo que nadie va a leer")
.expect_err("sin lector, escribir tiene que fallar");
assert_eq!(
error.kind(),
std::io::ErrorKind::BrokenPipe,
"el fallo del lector no llega como la tubería rota que es: {error}"
);
}
const INACTIVIDAD_DE_PRUEBA: Duration = Duration::from_secs(60);
const INACTIVIDAD_CORTA: Duration = Duration::from_millis(500);
fn orden_de_shell(guion: &str) -> Orden {
Orden {
cmd: vec!["sh".to_owned(), "-c".to_owned(), guion.to_owned()],
env: Vec::new(),
}
}
async fn texto_de_una_orden(cliente: &Cliente, contenedor: &str, guion: &str) -> String {
let mut salida = Vec::new();
cliente
.ejecutar(
contenedor,
&orden_de_shell(guion),
INACTIVIDAD_DE_PRUEBA,
|trozo: Trozo| {
if trozo.canal == Canal::Salida {
salida.extend_from_slice(&trozo.bytes);
}
},
)
.await
.expect("la sonda del contenedor no puede fallar por el transporte");
String::from_utf8_lossy(&salida).trim().to_owned()
}
fn alimentar_el_tunel(
escritor: EscritorDelVolcado,
contenido: Vec<u8>,
) -> std::thread::JoinHandle<()> {
std::thread::spawn(move || {
let mut escritor = escritor;
let _ = escritor.write_all(&contenido);
escritor.terminar_bien();
})
}
#[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_se_entrega_solo_a_la_ultima_orden() {
const BYTES: usize = 4_096;
let cliente = cliente_del_motor().expect("hace falta un socket de Docker o Podman");
let contenedor =
ContenedorDePrueba::nuevo("el_volcado_se_entrega_solo_a_la_ultima_orden").await;
let ordenes = vec![
orden_de_shell("wc -c > /tmp/recuento-de-la-retirada"),
orden_de_shell("wc -c > /tmp/recuento-de-la-reimportacion"),
];
let TunelDeRestauracion { escritor, lector } =
tunel_de_restauracion("volcado-de-la-base.sql", 4);
let hilo = alimentar_el_tunel(escritor, vec![b'k'; BYTES]);
let codigo = ejecutar_las_ordenes(
&cliente,
contenedor.nombre(),
&ordenes,
lector,
INACTIVIDAD_DE_PRUEBA,
)
.await
.expect("las dos órdenes salen con cero, así que no hay fallo que devolver");
assert_eq!(codigo, 0, "el volcado se entregó y la orden salió con 0");
let retirada = texto_de_una_orden(
&cliente,
contenedor.nombre(),
"cat /tmp/recuento-de-la-retirada",
)
.await;
assert_eq!(
retirada, "0",
"la orden de la retirada leyó {retirada} bytes por su `stdin`: tiene que correr con la \
entrada cerrada, sin esperar un volcado que no le corresponde"
);
let reimportacion = texto_de_una_orden(
&cliente,
contenedor.nombre(),
"cat /tmp/recuento-de-la-reimportacion",
)
.await;
assert_eq!(
reimportacion,
BYTES.to_string(),
"la última orden leyó {reimportacion} bytes del volcado: el volcado entero tiene que \
llegarle por su `stdin`"
);
hilo.join().expect("el hilo del volcado no se puede caer");
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 una_orden_que_falla_para_el_flujo_y_no_reimporta() {
let cliente = cliente_del_motor().expect("hace falta un socket de Docker o Podman");
let contenedor =
ContenedorDePrueba::nuevo("una_orden_que_falla_para_el_flujo_y_no_reimporta").await;
let ordenes = vec![
orden_de_shell("echo el-antes-no-se-pudo >&2; exit 3"),
orden_de_shell("wc -c > /tmp/el-testigo-de-la-reimportacion"),
];
let TunelDeRestauracion { escritor, lector } =
tunel_de_restauracion("volcado-de-la-base.sql", 4);
let hilo = alimentar_el_tunel(escritor, vec![b'k'; 4_096]);
let desenlace = ejecutar_las_ordenes(
&cliente,
contenedor.nombre(),
&ordenes,
lector,
INACTIVIDAD_DE_PRUEBA,
)
.await;
match desenlace {
Err(ErrorDelMotor::Restauracion { motivo }) => {
assert!(
motivo.contains('3') && motivo.contains("el-antes-no-se-pudo"),
"el motivo no lleva el código ni lo que dijo el contenedor: {motivo}"
);
}
otro => panic!("una orden que sale con 3 tiene que parar el flujo: {otro:?}"),
}
let sonda = texto_de_una_orden(
&cliente,
contenedor.nombre(),
"test -e /tmp/el-testigo-de-la-reimportacion && echo esta || echo no-esta",
)
.await;
assert_eq!(
sonda, "no-esta",
"la orden de la reimportación corrió después de que la anterior fallara"
);
hilo.join().expect("el hilo del volcado no se puede caer");
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 una_orden_que_no_drena_su_stdin_acaba_en_el_plazo_y_no_cuelga_el_flujo() {
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(
"una_orden_que_no_drena_su_stdin_acaba_en_el_plazo_y_no_cuelga_el_flujo",
)
.await;
let ordenes = vec![orden_de_shell("sleep 60")];
let TunelDeRestauracion { escritor, lector } =
tunel_de_restauracion("volcado-de-la-base.sql", 4);
let hilo = alimentar_el_tunel(escritor, vec![b'k'; BYTES]);
let desenlace = tokio::time::timeout(
ESPERA_DE_LA_PRUEBA,
ejecutar_las_ordenes(
&cliente,
contenedor.nombre(),
&ordenes,
lector,
INACTIVIDAD_CORTA,
),
)
.await
.expect(
"el flujo no concluyó dentro del plazo: la entrega sobrevivió al drenaje, y con ella \
se quedan la fila en `running` y el destino reservado",
);
match desenlace {
Err(ErrorDelMotor::Restauracion { motivo }) => assert!(
motivo.contains("no produjo salida") && motivo.contains(contenedor.nombre()),
"el motivo no dice que la orden se quedó callada y en qué contenedor: {motivo}"
),
otro => panic!("una orden que se calla más que el plazo tiene que fallar: {otro:?}"),
}
hilo.join()
.expect("el hilo que volcaba se quedó atascado: el lector no se soltó");
contenedor.retirar().await;
}
}