use std::path::PathBuf;
use std::time::Duration;
use thiserror::Error;
use crate::db::entities::{Target, TipoDeTarget};
use crate::docker::{Canal, Cliente, Orden, Trozo};
use crate::rustic::ProductorDelVolcado;
use crate::secret::{REDACTED, SecretString};
pub const CAPACIDAD_DEL_CANAL: usize = 128;
pub const INACTIVIDAD_MAXIMA: Duration = Duration::from_secs(300);
const LIMITE_DEL_MOTIVO: usize = 1024;
const BYTES_POR_CARACTER: usize = 4;
const TOPE_DEL_CRUDO: usize = LIMITE_DEL_MOTIVO * BYTES_POR_CARACTER + BYTES_POR_CARACTER;
#[derive(Debug, Clone)]
pub struct Objetivo {
pub tipo: TipoDeTarget,
pub container_name: Option<String>,
pub db_user: Option<String>,
pub db_password: Option<SecretString>,
pub db_name: Option<String>,
pub host_path: Option<String>,
pub db_path: Option<String>,
}
impl From<&Target> for Objetivo {
fn from(fila: &Target) -> Self {
Self {
tipo: fila.target_type,
container_name: fila.container_name.clone(),
db_user: fila.db_user.clone(),
db_password: fila.db_password.clone(),
db_name: fila.db_name.clone(),
host_path: fila.host_path.clone(),
db_path: fila.db_path.clone(),
}
}
}
#[derive(Debug)]
pub enum Ruta {
PorContenedor {
contenedor: String,
orden: Orden,
},
PorFichero {
ruta: PathBuf,
},
}
pub fn ruta(objetivo: &Objetivo) -> Result<Ruta, ErrorDeExtraccion> {
match objetivo.tipo {
TipoDeTarget::Directory => {
let ruta = exigido(objetivo, "host_path", objetivo.host_path.as_deref())?;
Ok(Ruta::PorFichero {
ruta: PathBuf::from(ruta),
})
}
TipoDeTarget::Mariadb => {
let (contenedor, usuario, base) = datos_de_una_base(objetivo)?;
let mut cmd = vec![
"mariadb-dump".to_owned(),
"--single-transaction".to_owned(),
"--quick".to_owned(),
"--lock-tables=false".to_owned(),
format!("-u{usuario}"),
];
cmd.push(match base {
Some(base) => base.to_owned(),
None => "--all-databases".to_owned(),
});
Ok(Ruta::PorContenedor {
contenedor: contenedor.to_owned(),
orden: 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 = match base {
Some(base) => vec![
"pg_dump".to_owned(),
"-U".to_owned(),
usuario.to_owned(),
"-d".to_owned(),
base.to_owned(),
],
None => vec!["pg_dumpall".to_owned(), "-U".to_owned(), usuario.to_owned()],
};
Ok(Ruta::PorContenedor {
contenedor: contenedor.to_owned(),
orden: 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)?;
Ok(Ruta::PorContenedor {
contenedor: contenedor.to_owned(),
orden: Orden {
cmd: vec![
"sqlite3".to_owned(),
"-readonly".to_owned(),
db_path.to_owned(),
".dump".to_owned(),
],
env: Vec::new(),
},
})
}
}
}
fn datos_de_una_base(objetivo: &Objetivo) -> Result<(&str, &str, Option<&str>), ErrorDeExtraccion> {
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: &Objetivo,
campo: &'static str,
valor: &'a str,
) -> Result<&'a str, ErrorDeExtraccion> {
if valor.starts_with('-') {
return Err(ErrorDeExtraccion::ValorQuePareceBandera {
tipo: nombre_del_tipo(objetivo.tipo),
campo,
});
}
Ok(valor)
}
#[derive(Debug)]
pub struct Resultado {
pub codigo: i32,
pub bytes_leidos: u64,
pub motivo_del_contenedor: String,
}
pub async fn volcar(
cliente: &Cliente,
contenedor: &str,
orden: &Orden,
productor: ProductorDelVolcado,
inactividad_maxima: Duration,
) -> Result<Resultado, ErrorDeExtraccion> {
let mut bytes_leidos: u64 = 0;
let mut crudo: Vec<u8> = Vec::new();
let mut sin_destino: Option<crate::rustic::Error> = None;
let codigo = {
let mut al_llegar = |trozo: Trozo| match trozo.canal {
Canal::Salida => {
bytes_leidos += trozo.bytes.len() as u64;
if sin_destino.is_none()
&& let Err(causa) = productor.enviar(trozo.bytes)
{
sin_destino = Some(causa);
}
}
Canal::Error => acumular_el_motivo(&mut crudo, &trozo.bytes),
};
cliente
.ejecutar(contenedor, orden, inactividad_maxima, &mut al_llegar)
.await
};
let codigo = match codigo {
Ok(codigo) => codigo,
Err(causa) => {
return Err(ErrorDeExtraccion::del_transporte(
contenedor,
causa,
motivo_para_guardar(crudo, orden),
));
}
};
if let Some(causa) = sin_destino {
return Err(ErrorDeExtraccion::ArchivadoSeRindio {
contenedor: contenedor.to_owned(),
causa,
motivo: motivo_para_guardar(crudo, orden),
});
}
if codigo == 0 {
productor.terminar();
} else {
drop(productor);
}
Ok(Resultado {
codigo,
bytes_leidos,
motivo_del_contenedor: motivo_para_guardar(crudo, orden),
})
}
#[derive(Debug, Error)]
pub enum ErrorDeExtraccion {
#[error(
"a un objetivo de tipo `{tipo}` le falta `{campo}`: sin él no hay extracció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 el alcance del volcado cambiaría sin que nada lo note"
)]
ValorQuePareceBandera {
tipo: &'static str,
campo: &'static str,
},
#[error("el contenedor `{contenedor}` no está: {motivo}")]
ContenedorQueNoEsta {
contenedor: String,
motivo: String,
},
#[error("{causa}{}", lo_que_dijo_el_contenedor(.motivo))]
Transporte {
#[source]
causa: crate::docker::Error,
motivo: String,
},
#[error(
"la extracción de `{contenedor}` se ha quedado sin destino: el archivado del volcado dejó de leer ({causa}){}",
lo_que_dijo_el_contenedor(.motivo)
)]
ArchivadoSeRindio {
contenedor: String,
#[source]
causa: crate::rustic::Error,
motivo: String,
},
}
fn lo_que_dijo_el_contenedor(motivo: &str) -> String {
if motivo.trim().is_empty() {
return String::new();
}
format!("; el contenedor dijo: {motivo}")
}
impl ErrorDeExtraccion {
fn del_transporte(contenedor: &str, causa: crate::docker::Error, motivo: String) -> Self {
match causa {
crate::docker::Error::MotorContesta {
status_code: 404,
message,
..
} if motivo.is_empty() => Self::ContenedorQueNoEsta {
contenedor: contenedor.to_owned(),
motivo: message,
},
causa => Self::Transporte { causa, motivo },
}
}
}
fn nombre_del_tipo(tipo: TipoDeTarget) -> &'static str {
match tipo {
TipoDeTarget::Mariadb => "mariadb",
TipoDeTarget::Postgresql => "postgresql",
TipoDeTarget::Directory => "directory",
TipoDeTarget::Sqlite => "sqlite",
}
}
fn exigido<'a>(
objetivo: &Objetivo,
campo: &'static str,
valor: Option<&'a str>,
) -> Result<&'a str, ErrorDeExtraccion> {
let tipo = nombre_del_tipo(objetivo.tipo);
match valor {
None => Err(ErrorDeExtraccion::CampoQueFalta { tipo, campo }),
Some(valor) if valor.trim().is_empty() => {
Err(ErrorDeExtraccion::CampoEnBlanco { tipo, campo })
}
Some(valor) => Ok(valor),
}
}
fn campo_opcional<'a>(
objetivo: &Objetivo,
campo: &'static str,
valor: Option<&'a str>,
) -> Result<Option<&'a str>, ErrorDeExtraccion> {
match valor {
None => Ok(None),
Some(valor) if valor.trim().is_empty() => Err(ErrorDeExtraccion::CampoEnBlanco {
tipo: nombre_del_tipo(objetivo.tipo),
campo,
}),
Some(valor) => Ok(Some(valor)),
}
}
fn entorno_del_exec(tipo: TipoDeTarget, contrasena: Option<&SecretString>) -> Vec<String> {
let Some(contrasena) = contrasena else {
return Vec::new();
};
let valor = contrasena.expose();
if valor.is_empty() {
return Vec::new();
}
let variable = match tipo {
TipoDeTarget::Mariadb => "MYSQL_PWD",
TipoDeTarget::Postgresql => "PGPASSWORD",
TipoDeTarget::Directory => return Vec::new(),
TipoDeTarget::Sqlite => return Vec::new(),
};
vec![format!("{variable}={valor}")]
}
fn acumular_el_motivo(crudo: &mut Vec<u8>, bytes: &[u8]) {
let libres = TOPE_DEL_CRUDO.saturating_sub(crudo.len());
let hasta = libres.min(bytes.len());
crudo.extend_from_slice(&bytes[..hasta]);
}
pub(crate) fn motivo_para_guardar(crudo: Vec<u8>, orden: &Orden) -> String {
let texto = String::from_utf8_lossy(&crudo);
recortar(&quitar_los_valores_del_entorno(&texto, orden))
}
fn quitar_los_valores_del_entorno(texto: &str, orden: &Orden) -> String {
let mut limpio = texto.to_owned();
let mut valores: Vec<&str> = valores_del_entorno(&orden.env)
.filter(|valor| !valor.is_empty())
.collect();
valores.sort_by_key(|valor| std::cmp::Reverse(valor.len()));
for valor in valores {
limpio = limpio.replace(valor, REDACTED);
}
limpio
}
fn valores_del_entorno(env: &[String]) -> impl Iterator<Item = &str> {
env.iter()
.filter_map(|variable| variable.split_once('=').map(|(_nombre, valor)| valor))
}
pub(crate) fn recortar(motivo: &str) -> String {
if motivo.chars().count() <= LIMITE_DEL_MOTIVO {
return motivo.to_owned();
}
let mut recortado: String = motivo.chars().take(LIMITE_DEL_MOTIVO - 1).collect();
recortado.push('…');
recortado
}
#[cfg(test)]
mod tests {
use std::io::Read;
use std::path::PathBuf;
use std::time::Duration;
use super::{
CAPACIDAD_DEL_CANAL, ErrorDeExtraccion, INACTIVIDAD_MAXIMA, LIMITE_DEL_MOTIVO, Objetivo,
Resultado, Ruta, TOPE_DEL_CRUDO, acumular_el_motivo, entorno_del_exec, motivo_para_guardar,
nombre_del_tipo, recortar, ruta, volcar,
};
use crate::db::entities::{Target, TipoDeTarget};
use crate::docker::{Cliente, Orden};
use crate::rustic::{ProductorDelVolcado, TunelDelVolcado, tunel_del_volcado};
use crate::secret::{REDACTED, SecretString};
use crate::test_support::{
BufferDeLog, ContenedorDePrueba, TIMEOUT_DEL_MOTOR, capturar_log, lector_del_volcado,
socket_del_motor,
};
use tracing_subscriber::EnvFilter;
const CONTRASENA: &str = "Zq7-clave-de-base-distinguible";
const PLAZO_ESTANCADO: Duration = Duration::from_secs(2);
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>) -> Objetivo {
Objetivo {
tipo: 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,
}
}
fn objetivo_postgresql(db_name: Option<&str>) -> Objetivo {
Objetivo {
tipo: TipoDeTarget::Postgresql,
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,
}
}
fn objetivo_directorio(host_path: Option<&str>) -> Objetivo {
Objetivo {
tipo: 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,
}
}
fn objetivo_sqlite(db_path: Option<&str>) -> Objetivo {
Objetivo {
tipo: TipoDeTarget::Sqlite,
container_name: Some("contenedor-de-la-base".to_owned()),
db_user: None,
db_password: None,
db_name: None,
host_path: None,
db_path: db_path.map(ToOwned::to_owned),
}
}
fn por_contenedor(objetivo: &Objetivo) -> (String, Orden) {
match ruta(objetivo).expect("esta ruta tiene que construirse") {
Ruta::PorContenedor { contenedor, orden } => (contenedor, orden),
Ruta::PorFichero { ruta } => panic!(
"un objetivo de tipo `{:?}` no puede salir por el sistema de ficheros: {ruta:?}",
objetivo.tipo
),
}
}
#[test]
fn el_comando_de_mariadb_sin_base_nombra_todo_el_servidor() {
let (contenedor, orden) = por_contenedor(&objetivo_mariadb(None));
assert_eq!(contenedor, "contenedor-de-la-base");
assert_eq!(
orden.cmd,
argv(&[
"mariadb-dump",
"--single-transaction",
"--quick",
"--lock-tables=false",
"-uoperador",
"--all-databases",
]),
"el comando de un volcado global no es el del §4.A: {:?}",
orden.cmd
);
}
#[test]
fn el_comando_de_mariadb_con_base_nombra_esa_base_y_solo_esa() {
let (_contenedor, orden) = por_contenedor(&objetivo_mariadb(Some("tienda")));
assert_eq!(
orden.cmd,
argv(&[
"mariadb-dump",
"--single-transaction",
"--quick",
"--lock-tables=false",
"-uoperador",
"tienda",
]),
"el comando no nombra la base pedida: {:?}",
orden.cmd
);
assert!(
!orden
.cmd
.iter()
.any(|argumento| argumento == "--all-databases"),
"un volcado de una sola base no puede llevarse el servidor entero: {:?}",
orden.cmd
);
}
#[test]
fn el_comando_de_postgresql_sin_base_usa_pg_dumpall() {
let (_contenedor, orden) = por_contenedor(&objetivo_postgresql(None));
assert_eq!(
orden.cmd,
argv(&["pg_dumpall", "-U", "operador"]),
"el comando de un volcado global no es `pg_dumpall`: {:?}",
orden.cmd
);
}
#[test]
fn el_comando_de_postgresql_con_base_usa_pg_dump() {
let (_contenedor, orden) = por_contenedor(&objetivo_postgresql(Some("almacen")));
assert_eq!(
orden.cmd,
argv(&["pg_dump", "-U", "operador", "-d", "almacen"]),
"el comando no volca la base pedida: {:?}",
orden.cmd
);
assert!(
!orden.cmd.iter().any(|argumento| argumento == "pg_dumpall"),
"un volcado de una sola base no puede llevarse el servidor entero: {:?}",
orden.cmd
);
}
#[test]
fn el_comando_de_un_sqlite_es_el_dump_de_su_fichero_y_no_un_cp() {
let (contenedor, orden) = por_contenedor(&objetivo_sqlite(Some("/var/lib/kesto/kesto.db")));
assert_eq!(contenedor, "contenedor-de-la-base");
assert_eq!(
orden.cmd,
argv(&["sqlite3", "-readonly", "/var/lib/kesto/kesto.db", ".dump"]),
"el comando de un sqlite no es `sqlite3 -readonly <db_path> .dump`: {:?}",
orden.cmd
);
assert!(
orden.cmd.iter().any(|argumento| argumento == "-readonly"),
"sin `-readonly` una ruta inexistente crea el fichero y el exit code miente: {:?}",
orden.cmd
);
for prohibido in ["cp", "cat", "tar", ".backup"] {
assert!(
!orden.cmd.iter().any(|argumento| argumento == prohibido),
"la copia de un sqlite no puede ser `{prohibido}`: el §4 pide el `.dump` por \
`stdout`, y {:?} no lo es",
orden.cmd
);
}
}
#[test]
fn un_sqlite_no_lleva_entorno() {
let (_contenedor, orden) =
por_contenedor(&objetivo_sqlite(Some("/var/lib/kesto/kesto.db")));
assert!(
orden.env.is_empty(),
"un sqlite no tiene credencial de base, así que no puede llevar entorno: {:?}",
orden.env
);
}
#[test]
fn un_sqlite_sin_db_path_utilizable_se_rechaza_nombrando_el_campo() {
for (db_path, esperado) in [(None, None), (Some(" "), Some("en blanco"))] {
let error = ruta(&objetivo_sqlite(db_path))
.expect_err("un sqlite sin una ruta utilizable no tiene comando");
match (error, esperado) {
(ErrorDeExtraccion::CampoQueFalta { tipo, campo }, None) => {
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`"
);
}
(ErrorDeExtraccion::CampoEnBlanco { tipo, campo }, Some(_)) => {
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:?}"
),
}
}
}
#[test]
fn un_db_path_que_parece_bandera_se_rechaza() {
let error = ruta(&objetivo_sqlite(Some("--version")))
.expect_err("un `db_path` que parece bandera no puede llegar al `exec`");
match error {
ErrorDeExtraccion::ValorQuePareceBandera { tipo, campo } => {
assert_eq!(tipo, "sqlite");
assert_eq!(campo, "db_path");
}
otro => panic!("el rechazo tiene que ser el de una bandera: {otro:?}"),
}
}
#[test]
fn un_sqlite_sin_container_name_se_rechaza_nombrando_el_campo() {
let mut objetivo = objetivo_sqlite(Some("/var/lib/kesto/kesto.db"));
objetivo.container_name = None;
let error = ruta(&objetivo).expect_err("un sqlite sin contenedor no tiene dónde ejecutar");
match error {
ErrorDeExtraccion::CampoQueFalta { tipo, campo } => {
assert_eq!(tipo, "sqlite");
assert_eq!(campo, "container_name");
}
otro => panic!("el rechazo tiene que ser el de un campo que falta: {otro:?}"),
}
}
#[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, orden) = por_contenedor(&objetivo);
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 ruta = ruta(&objetivo).expect("la ruta ya se construyó una vez");
assert!(
!format!("{ruta:?}").contains(CONTRASENA),
"el Debug de la ruta publica la contraseña: {ruta:?}"
);
}
}
#[test]
fn un_target_sin_contrasena_no_inventa_la_variable() {
for contrasena in [None, Some(secreto(""))] {
let objetivo = Objetivo {
db_password: contrasena,
..objetivo_mariadb(None)
};
let (_contenedor, orden) = por_contenedor(&objetivo);
assert!(
orden.env.is_empty(),
"un target sin contraseña no puede llevar entorno: {:?}",
orden.env
);
assert_eq!(
orden.cmd,
argv(&[
"mariadb-dump",
"--single-transaction",
"--quick",
"--lock-tables=false",
"-uoperador",
"--all-databases",
]),
"sin contraseña el comando tiene que ser el mismo: {:?}",
orden.cmd
);
}
}
#[test]
fn un_target_de_base_sin_usuario_no_se_puede_extraer() {
for tipo in [TipoDeTarget::Mariadb, TipoDeTarget::Postgresql] {
let objetivo = Objetivo {
db_user: None,
..objetivo_mariadb(None)
};
let objetivo = Objetivo { tipo, ..objetivo };
let error = ruta(&objetivo).expect_err("sin usuario no hay comando que construir");
assert!(
matches!(
&error,
ErrorDeExtraccion::CampoQueFalta {
campo: "db_user",
tipo: cual,
} if *cual == nombre_del_tipo(tipo)
),
"el fallo no nombra el campo que falta: {error:?}"
);
assert!(
error.to_string().contains("db_user"),
"el motivo no nombra el campo: {error}"
);
}
}
#[test]
fn un_target_de_base_sin_contenedor_no_se_puede_extraer() {
let objetivo = Objetivo {
container_name: None,
..objetivo_mariadb(None)
};
let error = ruta(&objetivo).expect_err("sin contenedor no hay dónde ejecutar la orden");
assert!(
matches!(
&error,
ErrorDeExtraccion::CampoQueFalta {
campo: "container_name",
..
}
),
"el fallo no nombra el contenedor que falta: {error:?}"
);
assert!(
error.to_string().contains("container_name"),
"el motivo no nombra el campo: {error}"
);
}
#[test]
fn un_campo_informado_pero_en_blanco_no_sirve() {
let caso = |objetivo: Objetivo, campo: &'static str| {
let error = ruta(&objetivo).expect_err("un campo en blanco no puede dar una ruta");
assert!(
matches!(
&error,
ErrorDeExtraccion::CampoEnBlanco { campo: cual, .. } if *cual == campo
),
"un campo en blanco tiene que rechazarse nombrando el campo `{campo}`: {error:?}"
);
};
caso(
Objetivo {
container_name: Some(" ".to_owned()),
..objetivo_mariadb(None)
},
"container_name",
);
caso(
Objetivo {
db_user: Some(String::new()),
..objetivo_postgresql(None)
},
"db_user",
);
caso(
Objetivo {
db_name: Some(" ".to_owned()),
..objetivo_mariadb(None)
},
"db_name",
);
caso(objetivo_directorio(Some("")), "host_path");
}
#[test]
fn un_db_name_nulo_no_es_un_campo_que_falte() {
por_contenedor(&objetivo_mariadb(None));
por_contenedor(&objetivo_postgresql(None));
}
#[test]
fn un_valor_que_parece_bandera_se_rechaza() {
let caso = |objetivo: Objetivo, campo: &'static str, valor: &str| {
let error =
ruta(&objetivo).expect_err("un valor que parece una bandera no da un comando");
let mensaje = error.to_string();
assert!(
matches!(
&error,
ErrorDeExtraccion::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}"
);
};
caso(
Objetivo {
db_name: Some("--all-databases".to_owned()),
..objetivo_mariadb(None)
},
"db_name",
"--all-databases",
);
caso(
Objetivo {
db_name: Some("-d".to_owned()),
..objetivo_postgresql(None)
},
"db_name",
"-d",
);
caso(
Objetivo {
db_user: Some("--user".to_owned()),
..objetivo_mariadb(None)
},
"db_user",
"--user",
);
caso(
Objetivo {
db_user: Some("-uperador".to_owned()),
..objetivo_postgresql(None)
},
"db_user",
"-uperador",
);
}
#[test]
fn el_nombre_del_contenedor_no_pasa_por_la_guarda_de_las_banderas() {
let objetivo = Objetivo {
container_name: Some("-contenedor".to_owned()),
..objetivo_mariadb(None)
};
let (contenedor, _) = por_contenedor(&objetivo);
assert_eq!(contenedor, "-contenedor");
}
#[test]
fn un_target_de_directorio_se_lee_del_sistema_de_ficheros() {
let ruta_esperada = PathBuf::from("/backup_sources/var_www");
match ruta(&objetivo_directorio(Some("/backup_sources/var_www")))
.expect("un directorio con su ruta da una ruta")
{
Ruta::PorFichero { ruta } => assert_eq!(ruta, ruta_esperada),
Ruta::PorContenedor { contenedor, orden } => panic!(
"un directorio no pasa por Docker, y salió por el contenedor `{contenedor}` con \
la orden {orden:?}"
),
}
}
#[test]
fn un_target_de_directorio_sin_ruta_no_se_puede_copiar() {
let error =
ruta(&objetivo_directorio(None)).expect_err("sin ruta no hay directorio que copiar");
assert!(
matches!(
&error,
ErrorDeExtraccion::CampoQueFalta {
campo: "host_path",
..
}
),
"el fallo no nombra la ruta que falta: {error:?}"
);
}
#[test]
fn el_motivo_del_contenedor_viaja_en_el_error_del_transporte() {
let no_esta = || crate::docker::Error::MotorContesta {
contenedor: "contenedor-de-la-base".to_owned(),
status_code: 404,
message: "no such container".to_owned(),
};
assert!(
matches!(
ErrorDeExtraccion::del_transporte(
"contenedor-de-la-base",
no_esta(),
String::new()
),
ErrorDeExtraccion::ContenedorQueNoEsta { .. }
),
"un 404 sin nada escrito sigue siendo el contenedor ausente"
);
let error = ErrorDeExtraccion::del_transporte(
"contenedor-de-la-base",
no_esta(),
format!("; el contenedor dijo: no puedo con {REDACTED}"),
);
let mensaje = error.to_string();
assert!(
matches!(&error, ErrorDeExtraccion::Transporte { motivo, .. } if motivo.contains(REDACTED)),
"el motivo del contenedor se tiró por el camino: {error:?}"
);
assert!(
mensaje.contains("el contenedor dijo"),
"el motivo no llega al mensaje, que es lo que se guarda: {mensaje}"
);
assert!(
mensaje.contains("404"),
"el 404 se perdió al no reconocerse como contenedor ausente: {mensaje}"
);
}
#[test]
fn un_motivo_vacio_no_deja_la_frase_del_contenedor_colgando() {
let causa = crate::docker::Error::ExtraccionEstancada {
contenedor: "contenedor-de-la-base".to_owned(),
inactividad: INACTIVIDAD_MAXIMA,
};
let error =
ErrorDeExtraccion::del_transporte("contenedor-de-la-base", causa, String::new());
let mensaje = error.to_string();
assert!(
!mensaje.contains("el contenedor dijo"),
"el mensaje dice que el contenedor habló sin que lo hiciera: {mensaje}"
);
assert!(
!mensaje.ends_with(' '),
"el mensaje deja un hueco donde iría el motivo: {mensaje:?}"
);
}
#[test]
fn el_objetivo_sale_de_la_fila_del_s3() {
let fila = Target {
id: uuid::Uuid::new_v4(),
name: "target-de-prueba".to_owned(),
target_type: TipoDeTarget::Postgresql,
container_name: Some("contenedor-de-la-base".to_owned()),
db_user: Some("operador".to_owned()),
db_password: Some(secreto(CONTRASENA)),
db_name: Some("almacen".to_owned()),
host_path: Some("/backup_sources/almacen".to_owned()),
db_path: Some("/var/lib/kesto/kesto.db".to_owned()),
created_at: time::OffsetDateTime::now_utc(),
};
let objetivo = Objetivo::from(&fila);
assert_eq!(objetivo.tipo, TipoDeTarget::Postgresql);
assert_eq!(
objetivo.container_name.as_deref(),
Some("contenedor-de-la-base")
);
assert_eq!(objetivo.db_user.as_deref(), Some("operador"));
assert_eq!(
objetivo.db_password.as_ref().map(SecretString::expose),
Some(CONTRASENA)
);
assert_eq!(objetivo.db_name.as_deref(), Some("almacen"));
assert_eq!(
objetivo.host_path.as_deref(),
Some("/backup_sources/almacen")
);
assert_eq!(objetivo.db_path.as_deref(), Some("/var/lib/kesto/kesto.db"));
}
#[test]
fn el_motivo_se_recorta_por_caracter_y_lo_dice() {
let mut motivo = "á".repeat(LIMITE_DEL_MOTIVO - 1);
motivo.push('€');
motivo.push_str(&"🦀".repeat(50));
motivo.push_str(&"á".repeat(LIMITE_DEL_MOTIVO));
let recortado = recortar(&motivo);
assert_eq!(
recortado.chars().count(),
LIMITE_DEL_MOTIVO,
"el motivo recortado no mide el límite: {} caracteres",
recortado.chars().count()
);
assert!(
recortado.ends_with('…'),
"un motivo cortado tiene que decirlo: …{}",
&recortado[recortado.len().saturating_sub(20)..]
);
assert!(
recortado
.chars()
.all(|caracter| caracter != char::REPLACEMENT_CHARACTER),
"el recorte partió un carácter por la mitad"
);
}
#[test]
fn un_motivo_corto_no_se_toca() {
let motivo = "mariadb-dump: Got error: 1045: Access denied for user 'operador'";
assert_eq!(recortar(motivo), motivo);
}
#[test]
fn el_limite_del_motivo_no_tiene_un_off_by_one() {
let justo = "a".repeat(LIMITE_DEL_MOTIVO);
assert_eq!(
recortar(&justo),
justo,
"un motivo que mide exactamente el límite cabe entero: no se recorta ni se le añade \
nada"
);
let recortado = recortar(&"a".repeat(LIMITE_DEL_MOTIVO + 1));
assert_eq!(
recortado,
format!("{}…", "a".repeat(LIMITE_DEL_MOTIVO - 1)),
"el motivo que mide uno más no se corta donde debe: conserva el límite menos el \
sitio del `…`"
);
assert_eq!(
recortado.chars().count(),
LIMITE_DEL_MOTIVO,
"el motivo recortado tiene que medir exactamente el límite"
);
}
#[test]
fn dos_valores_solapados_no_dejan_la_punta_del_mas_largo() {
let orden = Orden {
cmd: argv(&["mariadb-dump"]),
env: argv(&["OTRA=Zq7-clave", "MYSQL_PWD=Zq7-clave-de-base"]),
};
let motivo =
motivo_para_guardar("no puedo con Zq7-clave-de-base".as_bytes().to_vec(), &orden);
assert_eq!(
motivo,
format!("no puedo con {REDACTED}"),
"el motivo no quedó redactado entero: {motivo}"
);
assert!(
!motivo.contains("de-base"),
"el motivo guarda la cola del valor más largo: {motivo}"
);
}
#[test]
fn el_motivo_se_acumula_acotado() {
let trozo: Vec<u8> = "x".repeat(1024).into_bytes();
let mut crudo = Vec::new();
for _ in 0..64 {
acumular_el_motivo(&mut crudo, &trozo);
}
assert!(
crudo.len() <= TOPE_DEL_CRUDO,
"el búfer del motivo pasó del tope: {} bytes",
crudo.len()
);
assert_eq!(
crudo.len(),
TOPE_DEL_CRUDO,
"el búfer no llegó al tope, así que 64 trozos de 1 KiB no bastan para llenarlo"
);
assert!(
crudo.iter().all(|byte| *byte == b'x'),
"el búfer no guarda el principio de lo que escribió el contenedor"
);
acumular_el_motivo(&mut crudo, b"cola que no cabe en el motivo");
assert_eq!(
crudo.len(),
TOPE_DEL_CRUDO,
"el búfer creció después de llenarse"
);
let mut crudo = Vec::new();
acumular_el_motivo(&mut crudo, &"€".repeat(TOPE_DEL_CRUDO).into_bytes());
let texto = String::from_utf8_lossy(&crudo);
assert!(
texto.contains(char::REPLACEMENT_CHARACTER),
"el corte del tope no partió ningún carácter, así que esta prueba no está midiendo \
lo que dice"
);
let motivo = recortar(&texto);
assert_eq!(
motivo.chars().count(),
LIMITE_DEL_MOTIVO,
"el motivo recortado no mide el límite"
);
assert!(
!motivo.contains(char::REPLACEMENT_CHARACTER),
"el tope partió un carácter dentro de los que el recorte conserva: el margen de \
`TOPE_DEL_CRUDO` no está haciendo su trabajo"
);
}
#[test]
fn el_motivo_no_lleva_la_credencial() {
let orden = Orden {
cmd: argv(&["mariadb-dump", "-uoperador", "--all-databases"]),
env: vec![format!("MYSQL_PWD={CONTRASENA}")],
};
let crudo = format!(
"mariadb-dump: Got error: 1045: Access denied for user 'operador'@'localhost' \
(using password: YES) con la clave {CONTRASENA}, y otra vez {CONTRASENA}"
);
let motivo = motivo_para_guardar(crudo.into_bytes(), &orden);
assert!(
!motivo.contains(CONTRASENA),
"el motivo publica la contraseña: {motivo}"
);
assert!(
motivo.contains(REDACTED),
"el motivo no dice que haya algo redactado: {motivo}"
);
assert_eq!(
motivo.matches(REDACTED).count(),
2,
"no se sustituyeron todas las apariciones: {motivo}"
);
}
#[test]
fn el_recorte_no_deja_los_primeros_caracteres_de_la_credencial() {
let orden = Orden {
cmd: argv(&["mariadb-dump"]),
env: vec![format!("MYSQL_PWD={CONTRASENA}")],
};
let relleno = "x".repeat(LIMITE_DEL_MOTIVO - CONTRASENA.len() / 2);
let crudo = format!("{relleno}{CONTRASENA}{}", "y".repeat(100));
let motivo = motivo_para_guardar(crudo.into_bytes(), &orden);
for longitud in 1..=CONTRASENA.len() {
let prefijo = &CONTRASENA[..longitud];
assert!(
!motivo.contains(prefijo),
"el motivo guarda los primeros {longitud} caracteres de la contraseña: {motivo}"
);
}
}
#[test]
fn un_valor_de_entorno_vacio_no_destruye_el_motivo() {
let orden = Orden {
cmd: argv(&["mariadb-dump"]),
env: argv(&["MYSQL_PWD=", "SIN_IGUAL"]),
};
let motivo = motivo_para_guardar(b"el motivo de siempre".to_vec(), &orden);
assert_eq!(
motivo, "el motivo de siempre",
"un valor vacío —o una variable sin valor— destrozó el motivo"
);
}
#[test]
fn un_motivo_que_no_es_utf8_llega_igual_como_texto() {
let orden = Orden {
cmd: argv(&["mariadb-dump"]),
env: Vec::new(),
};
let motivo = motivo_para_guardar(b"fallo: \xF1and\xFA".to_vec(), &orden);
assert!(
motivo.starts_with("fallo:"),
"el motivo se perdió por un byte que no era UTF-8: {motivo}"
);
}
fn orden_de_shell(guion: &str) -> Orden {
Orden {
cmd: argv(&["sh", "-c", guion]),
env: Vec::new(),
}
}
fn tunel_de_prueba(nombre: &str) -> TunelDelVolcado {
tunel_del_volcado(nombre, CAPACIDAD_DEL_CANAL, INACTIVIDAD_MAXIMA)
.expect("el nombre del volcado de prueba es válido")
}
fn volcar_en_su_hilo(
contenedor: &str,
orden: Orden,
productor: ProductorDelVolcado,
) -> std::thread::JoinHandle<Result<Resultado, ErrorDeExtraccion>> {
volcar_en_su_hilo_con_plazo(contenedor, orden, productor, INACTIVIDAD_MAXIMA)
}
fn volcar_en_su_hilo_con_plazo(
contenedor: &str,
orden: Orden,
productor: ProductorDelVolcado,
inactividad_maxima: Duration,
) -> std::thread::JoinHandle<Result<Resultado, ErrorDeExtraccion>> {
let socket = socket_del_motor().expect("las pruebas del motor necesitan un socket");
let contenedor = contenedor.to_owned();
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 cliente =
Cliente::conectar(&socket, TIMEOUT_DEL_MOTOR).expect("el socket existe");
volcar(&cliente, &contenedor, &orden, productor, inactividad_maxima).await
})
})
}
#[tokio::test]
#[ignore = "requiere un motor de contenedores, y el CI no levanta ninguno: se ejecuta con cargo test -- --ignored"]
async fn la_extraccion_entrega_el_volcado_entero_y_su_motivo() {
const VOLCADO: &[u8] = b"volcado";
const MOTIVO: &str = "motivo";
let contenedor =
ContenedorDePrueba::nuevo("la_extraccion_entrega_el_volcado_entero_y_su_motivo").await;
let TunelDelVolcado {
origen,
productor,
entrega,
avance: _,
} = tunel_de_prueba("volcado-de-tienda.sql");
let orden = orden_de_shell("printf volcado; printf motivo >&2; exit 0");
let hilo = volcar_en_su_hilo(contenedor.nombre(), orden, productor);
let mut lector = lector_del_volcado(&origen);
let mut leido = Vec::new();
lector
.read_to_end(&mut leido)
.expect("el volcado se declaró terminado, así que la lectura no puede fallar");
let resultado = hilo
.join()
.expect("el hilo que conduce el `exec` no puede entrar en pánico")
.expect("una extracción que sale con cero no es un fallo de la extracción");
assert_eq!(resultado.codigo, 0, "el guion sale con cero");
assert_eq!(
leido,
VOLCADO,
"al lector no le llegó exactamente el volcado, sino {:?}",
String::from_utf8_lossy(&leido)
);
assert_eq!(
resultado.bytes_leidos,
VOLCADO.len() as u64,
"la cuenta de bytes no cuadra con lo que el comando escribió"
);
assert_eq!(
resultado.motivo_del_contenedor, MOTIVO,
"el motivo del contenedor no se recogió tal cual"
);
assert!(
entrega.completa(),
"el volcado se entregó entero y el proceso salió con cero, y la señal sigue en \
falso: el motor daría por fallida una copia buena"
);
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_proceso_que_falla_no_declara_el_final() {
let contenedor =
ContenedorDePrueba::nuevo("un_proceso_que_falla_no_declara_el_final").await;
let TunelDelVolcado {
origen,
productor,
entrega,
avance: _,
} = tunel_de_prueba("volcado-que-falla.sql");
let orden = orden_de_shell("printf algo; printf fallo-del-volcado >&2; exit 3");
let hilo = volcar_en_su_hilo(contenedor.nombre(), orden, productor);
let mut lector = lector_del_volcado(&origen);
let mut leido = Vec::new();
let lectura = lector.read_to_end(&mut leido);
let resultado = hilo
.join()
.expect("el hilo que conduce el `exec` no puede entrar en pánico")
.expect("un proceso que sale con 3 llegó a su término: hay hechos que contar");
assert_eq!(
resultado.codigo, 3,
"el código de salida no es el del proceso"
);
assert_eq!(
resultado.motivo_del_contenedor, "fallo-del-volcado",
"el motivo que escribió el contenedor se perdió"
);
assert_eq!(
leido, b"algo",
"los bytes que el proceso escribió antes de fallar no llegaron"
);
assert!(
lectura.is_err(),
"el lector devolvió un fin de fichero limpio sobre un volcado truncado: la \
biblioteca lo leería como «el volcado se acabó» y daría la copia por buena"
);
assert!(
!entrega.completa(),
"el proceso falló y la señal dice que el volcado se entregó entero"
);
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_volcado_grande_llega_entero_y_contado() {
const BYTES_ESCRITOS: usize = 4 * 1024 * 1024;
let contenedor =
ContenedorDePrueba::nuevo("un_volcado_grande_llega_entero_y_contado").await;
let TunelDelVolcado {
origen,
productor,
entrega,
avance: _,
} = tunel_de_prueba("volcado-grande.sql");
let orden = orden_de_shell(&format!("yes a | head -c {BYTES_ESCRITOS}"));
let hilo = volcar_en_su_hilo(contenedor.nombre(), orden, productor);
let mut lector = lector_del_volcado(&origen);
let mut leido = Vec::new();
lector
.read_to_end(&mut leido)
.expect("el volcado se declaró terminado, así que la lectura no puede fallar");
let resultado = hilo
.join()
.expect("el hilo que conduce el `exec` no puede entrar en pánico")
.expect("un `stdout` grande no es un fallo de la extracción");
assert_eq!(resultado.codigo, 0, "el comando sale con cero");
assert_eq!(
resultado.bytes_leidos, BYTES_ESCRITOS as u64,
"la cuenta de bytes leídos no cuadra con lo que el comando escribió"
);
assert_eq!(
leido.len(),
BYTES_ESCRITOS,
"los bytes que llegaron al lector no son los que se escribieron"
);
assert!(
entrega.completa(),
"el volcado llegó entero y la señal de la entrega sigue en falso"
);
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 el_motivo_de_un_contenedor_que_repite_la_contrasena_no_la_publica() {
let contenedor = ContenedorDePrueba::nuevo(
"el_motivo_de_un_contenedor_que_repite_la_contrasena_no_la_publica",
)
.await;
let TunelDelVolcado {
origen,
productor,
entrega: _,
avance: _,
} = tunel_de_prueba("volcado-sin-credencial.sql");
let orden = Orden {
env: vec![format!("MYSQL_PWD={CONTRASENA}")],
..orden_de_shell("printf 'no puedo con %s' \"$MYSQL_PWD\" >&2; exit 1")
};
let hilo = volcar_en_su_hilo(contenedor.nombre(), orden, productor);
let mut lector = lector_del_volcado(&origen);
let mut leido = Vec::new();
let _ = lector.read_to_end(&mut leido);
let resultado = hilo
.join()
.expect("el hilo que conduce el `exec` no puede entrar en pánico")
.expect("el proceso llegó a su término: hay hechos que contar");
assert_eq!(resultado.codigo, 1, "el guion sale con uno");
assert!(
!resultado.motivo_del_contenedor.contains(CONTRASENA),
"el motivo que se guardaría publica la contraseña de la base: {}",
resultado.motivo_del_contenedor
);
assert!(
resultado.motivo_del_contenedor.contains(REDACTED),
"el motivo no dice que haya algo redactado: {}",
resultado.motivo_del_contenedor
);
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_esta_no_se_confunde_con_un_fallo_de_volcado() {
const AUSENTE: &str = "kesto-prueba-contenedor-que-no-existe-extraccion";
let TunelDelVolcado {
origen: _origen,
productor,
entrega: _,
avance: _,
} = tunel_de_prueba("volcado-sin-contenedor.sql");
let error = volcar_en_su_hilo(AUSENTE, orden_de_shell("echo nunca-llega"), productor)
.join()
.expect("el hilo que conduce el `exec` no puede entrar en pánico")
.expect_err("un contenedor que no existe no puede ejecutar nada");
assert!(
matches!(
&error,
ErrorDeExtraccion::ContenedorQueNoEsta { contenedor, .. } if contenedor == AUSENTE
),
"el contenedor ausente no se reconoció como tal: {error:?}"
);
assert!(
error.to_string().contains(AUSENTE),
"el motivo no nombra el contenedor: {error}"
);
}
#[tokio::test]
#[ignore = "requiere un motor de contenedores, y el CI no levanta ninguno: se ejecuta con cargo test -- --ignored"]
async fn un_proceso_que_falla_sin_escribir_en_la_salida_no_es_una_base_vacia() {
let contenedor = ContenedorDePrueba::nuevo(
"un_proceso_que_falla_sin_escribir_en_la_salida_no_es_una_base_vacia",
)
.await;
let TunelDelVolcado {
origen,
productor,
entrega,
avance: _,
} = tunel_de_prueba("volcado-sin-cliente.sql");
let orden = Orden {
cmd: argv(&["mariadb-dump", "--single-transaction", "--all-databases"]),
env: Vec::new(),
};
let hilo = volcar_en_su_hilo(contenedor.nombre(), orden, productor);
let mut lector = lector_del_volcado(&origen);
let mut leido = Vec::new();
let lectura = lector.read_to_end(&mut leido);
let resultado = hilo
.join()
.expect("el hilo que conduce el `exec` no puede entrar en pánico")
.expect("el proceso llegó a su término: hay hechos que contar");
assert_ne!(
resultado.codigo, 0,
"el binario no está en la imagen, así que el proceso no puede salir con cero"
);
assert_eq!(
resultado.bytes_leidos, 0,
"un binario ausente no escribe nada por su salida: la cuenta de bytes sería el \
volcado y aquí no hay ninguno"
);
assert!(
leido.is_empty(),
"al lector no puede llegarle nada: {} bytes",
leido.len()
);
assert!(
!resultado.motivo_del_contenedor.is_empty(),
"el motivo del contenedor se perdió, y es la única mitad que distingue este fallo de \
una base vacía"
);
assert!(
lectura.is_err(),
"el lector devolvió un fin de fichero limpio sobre un volcado que nunca se escribió: \
la biblioteca lo leería como «el volcado se acabó» y daría la copia por buena"
);
assert!(
!entrega.completa(),
"una extracción que falló sin escribir nada no puede darse por entregada"
);
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_acaba_en_estancamiento_y_sin_entrega() {
let contenedor = ContenedorDePrueba::nuevo(
"una_orden_que_se_calla_acaba_en_estancamiento_y_sin_entrega",
)
.await;
let TunelDelVolcado {
origen,
productor,
entrega,
avance: _,
} = tunel_de_prueba("volcado-estancado.sql");
let orden = Orden {
env: vec![format!("MYSQL_PWD={CONTRASENA}")],
..orden_de_shell("printf 'no puedo con %s' \"$MYSQL_PWD\" >&2; sleep 600")
};
let hilo =
volcar_en_su_hilo_con_plazo(contenedor.nombre(), orden, productor, PLAZO_ESTANCADO);
let mut lector = lector_del_volcado(&origen);
let mut leido = Vec::new();
let lectura = lector.read_to_end(&mut leido);
let error = hilo
.join()
.expect("el hilo que conduce el `exec` no puede entrar en pánico")
.expect_err("una orden que se calla más que el plazo no puede darse por buena");
assert!(
matches!(
&error,
ErrorDeExtraccion::Transporte {
causa: crate::docker::Error::ExtraccionEstancada {
contenedor: cual,
inactividad,
},
..
} if cual == contenedor.nombre() && *inactividad == PLAZO_ESTANCADO
),
"el vencimiento no llegó como estancamiento con el plazo que se le dio: {error:?}"
);
let mensaje = error.to_string();
assert!(
mensaje.contains("no puedo con"),
"el error se dejó lo que el contenedor alcanzó a decir por su canal de error: \
{mensaje}"
);
assert!(
!mensaje.contains(CONTRASENA),
"el error publica la credencial de la base: {mensaje}"
);
assert!(
leido.is_empty(),
"el guion no escribe nada por su salida: {} bytes",
leido.len()
);
assert!(
lectura.is_err(),
"el lector devolvió un fin de fichero limpio sobre un volcado que no se declaró \
terminado"
);
assert!(
!entrega.completa(),
"el volcado no se declaró terminado y la señal dice que se entregó entero"
);
contenedor.retirar().await;
}
struct PuenteDelMotor;
impl log::Log for PuenteDelMotor {
fn enabled(&self, metadatos: &log::Metadata<'_>) -> bool {
metadatos.target().starts_with(crate::OBJETIVO_DEL_MOTOR)
}
fn log(&self, registro: &log::Record<'_>) {
if !self.enabled(registro.metadata()) {
return;
}
match registro.level() {
log::Level::Error => tracing::event!(
target: crate::OBJETIVO_DEL_MOTOR,
tracing::Level::ERROR,
"{}",
registro.args()
),
log::Level::Warn => tracing::event!(
target: crate::OBJETIVO_DEL_MOTOR,
tracing::Level::WARN,
"{}",
registro.args()
),
log::Level::Info => tracing::event!(
target: crate::OBJETIVO_DEL_MOTOR,
tracing::Level::INFO,
"{}",
registro.args()
),
log::Level::Debug => tracing::event!(
target: crate::OBJETIVO_DEL_MOTOR,
tracing::Level::DEBUG,
"{}",
registro.args()
),
log::Level::Trace => tracing::event!(
target: crate::OBJETIVO_DEL_MOTOR,
tracing::Level::TRACE,
"{}",
registro.args()
),
}
}
fn flush(&self) {}
}
fn instalar_el_puente_del_motor() {
static PUENTE: PuenteDelMotor = PuenteDelMotor;
static INSTALADO: std::sync::OnceLock<()> = std::sync::OnceLock::new();
INSTALADO.get_or_init(|| {
let _ = log::set_logger(&PUENTE);
log::set_max_level(log::LevelFilter::Trace);
});
}
#[test]
fn la_credencial_de_la_base_no_sale_en_el_registro_ni_al_nivel_mas_hablador() {
instalar_el_puente_del_motor();
let (_del_arnes, guardia) = capturar_log();
let env = entorno_del_exec(TipoDeTarget::Mariadb, Some(&secreto(CONTRASENA)));
assert!(
env.iter().any(|variable| variable.contains(CONTRASENA)),
"el entorno del `exec` no lleva la contraseña, así que esta prueba no mediría nada: \
{env:?}"
);
let (compuesto, _aviso) = crate::componer_el_filtro("debug");
let del_proceso = BufferDeLog::default();
let con_la_directiva = tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_new(&compuesto).expect("el filtro del proceso es válido"),
)
.with_ansi(false)
.without_time()
.with_writer(del_proceso.clone())
.finish();
tracing::subscriber::with_default(con_la_directiva, || {
log::debug!(
target: crate::OBJETIVO_DEL_MOTOR,
"serialize_payload: {{\"env\": {env:?}}}"
);
});
let registro_del_proceso = del_proceso.texto();
assert!(
!registro_del_proceso.contains(CONTRASENA),
"el registro del proceso publica la contraseña de la base con el nivel más hablador: \
{registro_del_proceso}"
);
let desnudo = BufferDeLog::default();
let sin_la_directiva = tracing_subscriber::fmt()
.with_env_filter(EnvFilter::new("debug"))
.with_ansi(false)
.without_time()
.with_writer(desnudo.clone())
.finish();
tracing::subscriber::with_default(sin_la_directiva, || {
log::debug!(
target: crate::OBJETIVO_DEL_MOTOR,
"serialize_payload: {{\"env\": {env:?}}}"
);
});
let registro_desnudo = desnudo.texto();
drop(guardia);
assert!(
registro_desnudo.contains(CONTRASENA),
"el mismo registro no llega ni con un filtro sin la directiva, así que el puente o el \
buffer no están midiendo nada y la ausencia de arriba no significa nada: \
{registro_desnudo}"
);
}
}