use std::sync::Arc;
use sqlx::Row;
use sqlx::SqlitePool;
use sqlx::sqlite::SqliteRow;
use time::OffsetDateTime;
use uuid::Uuid;
use crate::db::DbError;
use crate::db::crypto::Clave;
use crate::secret::SecretString;
#[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type)]
#[sqlx(rename_all = "lowercase")]
pub enum TipoDeTarget {
Mariadb,
Postgresql,
Directory,
Sqlite,
}
#[derive(Debug, Clone)]
pub struct Target {
pub id: Uuid,
pub name: String,
pub target_type: 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>,
pub created_at: OffsetDateTime,
}
#[derive(Debug, Clone)]
pub struct AltaDeTarget {
pub name: String,
pub target_type: 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>,
}
pub struct Targets {
pool: SqlitePool,
clave: Arc<Clave>,
}
const SQL_LEER: &str = "SELECT id, name, target_type, container_name, db_user, db_password, db_name, \
host_path, db_path, created_at FROM backup_targets WHERE id = ?1";
const SQL_LISTAR: &str = "SELECT id, name, target_type, container_name, db_user, db_password, db_name, \
host_path, db_path, created_at FROM backup_targets ORDER BY created_at, id";
const SQL_ALTA: &str = "INSERT INTO backup_targets \
(id, name, target_type, container_name, db_user, db_password, db_name, host_path, db_path) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) \
RETURNING id, name, target_type, container_name, db_user, db_password, db_name, host_path, \
db_path, created_at";
const SQL_ACTUALIZAR: &str = "UPDATE backup_targets SET name = ?1, target_type = ?2, container_name = ?3, \
db_user = ?4, db_password = ?5, db_name = ?6, host_path = ?7, db_path = ?8 WHERE id = ?9";
const SQL_BORRAR: &str = "DELETE FROM backup_targets WHERE id = ?1";
const CONSULTA_MAPEO: &str = "mapeo de la fila de backup_targets";
impl Targets {
pub fn nuevo(pool: SqlitePool, clave: Arc<Clave>) -> Self {
Targets { pool, clave }
}
pub async fn alta(&self, alta: AltaDeTarget) -> Result<Target, DbError> {
let id = Uuid::new_v4();
if let Some(claro) = &alta.db_password {
exigir_no_vacio("db_password", claro)?;
}
let db_password = match &alta.db_password {
Some(claro) => Some(self.clave.cifrar(claro)?),
None => None,
};
let fila = sqlx::query(SQL_ALTA)
.bind(id.hyphenated().to_string())
.bind(&alta.name)
.bind(alta.target_type)
.bind(&alta.container_name)
.bind(&alta.db_user)
.bind(&db_password)
.bind(&alta.db_name)
.bind(&alta.host_path)
.bind(&alta.db_path)
.fetch_one(&self.pool)
.await
.map_err(error_de_consulta(SQL_ALTA))?;
desde_fila(&fila, &self.clave)
}
pub async fn leer(&self, id: &Uuid) -> Result<Option<Target>, DbError> {
let fila = sqlx::query(SQL_LEER)
.bind(id.hyphenated().to_string())
.fetch_optional(&self.pool)
.await
.map_err(error_de_consulta(SQL_LEER))?;
match fila {
Some(fila) => desde_fila(&fila, &self.clave).map(Some),
None => Ok(None),
}
}
pub async fn listar(&self) -> Result<Vec<Target>, DbError> {
let filas = sqlx::query(SQL_LISTAR)
.fetch_all(&self.pool)
.await
.map_err(error_de_consulta(SQL_LISTAR))?;
filas
.iter()
.map(|fila| desde_fila(fila, &self.clave))
.collect()
}
pub async fn actualizar(&self, target: &Target) -> Result<bool, DbError> {
if let Some(claro) = &target.db_password {
exigir_no_vacio("db_password", claro)?;
}
let db_password = match &target.db_password {
Some(claro) => Some(self.clave.cifrar(claro)?),
None => None,
};
let resultado = sqlx::query(SQL_ACTUALIZAR)
.bind(&target.name)
.bind(target.target_type)
.bind(&target.container_name)
.bind(&target.db_user)
.bind(&db_password)
.bind(&target.db_name)
.bind(&target.host_path)
.bind(&target.db_path)
.bind(target.id.hyphenated().to_string())
.execute(&self.pool)
.await
.map_err(error_de_consulta(SQL_ACTUALIZAR))?;
Ok(resultado.rows_affected() == 1)
}
pub async fn borrar(&self, id: &Uuid) -> Result<bool, DbError> {
let resultado = sqlx::query(SQL_BORRAR)
.bind(id.hyphenated().to_string())
.execute(&self.pool)
.await
.map_err(error_de_consulta(SQL_BORRAR))?;
Ok(resultado.rows_affected() == 1)
}
}
fn error_de_consulta(consulta: &'static str) -> impl FnOnce(sqlx::Error) -> DbError {
move |causa| DbError::Consulta { consulta, causa }
}
fn exigir_no_vacio(campo: &'static str, secreto: &SecretString) -> Result<(), DbError> {
if secreto.expose().is_empty() {
return Err(DbError::SecretoVacio { campo });
}
Ok(())
}
fn columna<'fila, T>(fila: &'fila SqliteRow, nombre: &str) -> Result<T, DbError>
where
T: sqlx::Decode<'fila, sqlx::Sqlite> + sqlx::Type<sqlx::Sqlite>,
{
fila.try_get::<T, _>(nombre)
.map_err(|causa| DbError::Consulta {
consulta: CONSULTA_MAPEO,
causa,
})
}
fn desde_fila(fila: &SqliteRow, clave: &Clave) -> Result<Target, DbError> {
let id = columna::<String>(fila, "id")?;
let id = Uuid::parse_str(&id).map_err(|causa| DbError::Consulta {
consulta: CONSULTA_MAPEO,
causa: sqlx::Error::Decode(Box::new(causa)),
})?;
let db_password = match columna::<Option<String>>(fila, "db_password")? {
Some(cifrado) => Some(clave.descifrar(&cifrado)?),
None => None,
};
Ok(Target {
id,
name: columna(fila, "name")?,
target_type: columna(fila, "target_type")?,
container_name: columna(fila, "container_name")?,
db_user: columna(fila, "db_user")?,
db_password,
db_name: columna(fila, "db_name")?,
host_path: columna(fila, "host_path")?,
db_path: columna(fila, "db_path")?,
created_at: columna(fila, "created_at")?,
})
}
#[cfg(test)]
mod tests {
use sqlx::SqlitePool;
use time::OffsetDateTime;
use super::{AltaDeTarget, Targets, TipoDeTarget};
use crate::db::DbError;
use crate::db::entities::execution::{AltaDeEjecucion, Ejecuciones, Estado};
use crate::db::entities::job::{AltaDeTarea, Tareas};
use crate::db::entities::repository::{AltaDeRepositorio, Repositorios};
use crate::secret::SecretString;
use crate::test_support::{DirectorioDePrueba, base_con_clave};
const PASSWORD: &str = "db-password-distinguible-0000000000000000000000001";
fn secreto(valor: &str) -> SecretString {
SecretString::new(valor.to_owned())
}
fn alta_mariadb(nombre: &str) -> AltaDeTarget {
AltaDeTarget {
name: nombre.to_owned(),
target_type: TipoDeTarget::Mariadb,
container_name: Some(format!("contenedor-{nombre}")),
db_user: Some("usuario-de-base".to_owned()),
db_password: Some(secreto(PASSWORD)),
db_name: Some(format!("base-{nombre}")),
host_path: None,
db_path: None,
}
}
fn alta_directorio(nombre: &str, ruta: &str) -> AltaDeTarget {
AltaDeTarget {
name: nombre.to_owned(),
target_type: TipoDeTarget::Directory,
container_name: None,
db_user: None,
db_password: None,
db_name: None,
host_path: Some(ruta.to_owned()),
db_path: None,
}
}
fn alta_sqlite(nombre: &str, ruta: &str) -> AltaDeTarget {
AltaDeTarget {
name: nombre.to_owned(),
target_type: TipoDeTarget::Sqlite,
container_name: Some(format!("contenedor-{nombre}")),
db_user: None,
db_password: None,
db_name: None,
host_path: None,
db_path: Some(ruta.to_owned()),
}
}
async fn capa() -> (DirectorioDePrueba, SqlitePool, Targets) {
let (directorio, pool, clave) = base_con_clave().await;
let targets = Targets::nuevo(pool.clone(), clave);
(directorio, pool, targets)
}
#[tokio::test]
async fn el_ciclo_de_un_target_y_su_tipo_guardado_como_texto() {
let (_directorio, pool, targets) = capa().await;
let dado_de_alta = targets
.alta(alta_mariadb("target-1-25"))
.await
.expect("el alta no puede fallar");
let leido = targets
.leer(&dado_de_alta.id)
.await
.expect("la lectura no puede fallar")
.expect("la fila recién dada de alta debe estar");
assert_eq!(leido.id, dado_de_alta.id, "el id leído no es el del alta");
assert_eq!(leido.name, "target-1-25");
assert_eq!(leido.target_type, TipoDeTarget::Mariadb);
assert_eq!(
leido.container_name.as_deref(),
Some("contenedor-target-1-25")
);
assert_eq!(leido.db_user.as_deref(), Some("usuario-de-base"));
assert_eq!(
leido.db_password.as_ref().map(SecretString::expose),
Some(PASSWORD)
);
assert_eq!(leido.db_name.as_deref(), Some("base-target-1-25"));
assert_eq!(leido.host_path, None);
assert_eq!(leido.created_at, dado_de_alta.created_at);
let almacenado: String =
sqlx::query_scalar("SELECT target_type FROM backup_targets WHERE id = ?1")
.bind(dado_de_alta.id.hyphenated().to_string())
.fetch_one(&pool)
.await
.expect("el tipo del target debe poder leerse");
assert_eq!(
almacenado, "mariadb",
"el enumerado no se guardó como texto, sino como {almacenado}"
);
let listado = targets.listar().await.expect("el listado no puede fallar");
assert_eq!(listado.len(), 1, "el listado debe traer el target");
let mut cambiado = leido.clone();
cambiado.name = "target-1-25-despues".to_owned();
cambiado.db_password = Some(secreto("db-password-rotada-1-25"));
assert!(
targets
.actualizar(&cambiado)
.await
.expect("la actualización no puede fallar"),
"la fila existía, así que la actualización debe decir que sí"
);
let releido = targets
.leer(&cambiado.id)
.await
.expect("la lectura no puede fallar")
.expect("la fila debe seguir existiendo");
assert_eq!(
releido.name, "target-1-25-despues",
"el campo tocado no cambió"
);
assert_eq!(
releido.db_password.as_ref().map(SecretString::expose),
Some("db-password-rotada-1-25"),
"el secreto tocado no cambió"
);
assert_eq!(
releido.created_at, dado_de_alta.created_at,
"el created_at no debía cambiar"
);
assert!(
targets
.borrar(&cambiado.id)
.await
.expect("el borrado no puede fallar"),
"la fila existía, así que el borrado debe decir que sí"
);
let tras_borrar = targets
.leer(&cambiado.id)
.await
.expect("la lectura no puede fallar");
assert!(tras_borrar.is_none(), "la fila debía desaparecer");
}
#[tokio::test]
async fn el_db_name_nulo_no_se_confunde_con_la_cadena_vacia() {
let (_directorio, pool, targets) = capa().await;
let con_nombre = targets
.alta(alta_mariadb("target-1-26-con"))
.await
.expect("el alta no puede fallar");
let sin_nombre = targets
.alta(AltaDeTarget {
db_name: None,
..alta_mariadb("target-1-26-sin")
})
.await
.expect("el alta no puede fallar");
let leido_con = targets
.leer(&con_nombre.id)
.await
.expect("la lectura no puede fallar")
.expect("la fila debe estar");
assert_eq!(
leido_con.db_name.as_deref(),
Some("base-target-1-26-con"),
"un db_name informado debe leerse informado"
);
let leido_sin = targets
.leer(&sin_nombre.id)
.await
.expect("la lectura no puede fallar")
.expect("la fila debe estar");
assert_eq!(
leido_sin.db_name, None,
"un db_name NULL debe leerse como `None`"
);
let es_nulo: i64 =
sqlx::query_scalar("SELECT db_name IS NULL FROM backup_targets WHERE id = ?1")
.bind(sin_nombre.id.hyphenated().to_string())
.fetch_one(&pool)
.await
.expect("el `IS NULL` debe poder leerse");
assert_eq!(es_nulo, 1, "la columna no es NULL, sino otra cosa");
let longitud: Option<i64> =
sqlx::query_scalar("SELECT length(db_name) FROM backup_targets WHERE id = ?1")
.bind(sin_nombre.id.hyphenated().to_string())
.fetch_one(&pool)
.await
.expect("la longitud debe poder leerse");
assert_eq!(
longitud, None,
"un db_name NULL no debe medir 0: eso sería la cadena vacía"
);
}
#[tokio::test]
async fn un_target_de_directorio_no_necesita_contenedor_ni_credenciales() {
let (_directorio, _pool, targets) = capa().await;
let dado_de_alta = targets
.alta(alta_directorio("target-1-27", "/srv/datos"))
.await
.expect("un directorio no necesita contenedor ni credenciales");
let leido = targets
.leer(&dado_de_alta.id)
.await
.expect("la lectura no puede fallar")
.expect("la fila debe estar");
assert_eq!(leido.target_type, TipoDeTarget::Directory);
assert_eq!(leido.host_path.as_deref(), Some("/srv/datos"));
assert_eq!(leido.container_name, None);
assert_eq!(leido.db_user, None);
assert!(
leido.db_password.is_none(),
"un target de directorio no lleva contraseña de base"
);
assert_eq!(leido.db_name, None);
assert_eq!(
leido.db_path, None,
"un target de directorio no lleva la ruta de un .db"
);
}
#[tokio::test]
async fn un_target_sqlite_da_ida_y_vuelta_con_su_db_path() {
let (_directorio, pool, targets) = capa().await;
let dado_de_alta = targets
.alta(alta_sqlite("target-1-54", "/var/lib/kesto/kesto.db"))
.await
.expect("el alta de un target sqlite no puede fallar");
assert_eq!(dado_de_alta.target_type, TipoDeTarget::Sqlite);
assert_eq!(
dado_de_alta.db_path.as_deref(),
Some("/var/lib/kesto/kesto.db")
);
let leido = targets
.leer(&dado_de_alta.id)
.await
.expect("la lectura no puede fallar")
.expect("la fila recién dada de alta debe estar");
assert_eq!(leido.target_type, TipoDeTarget::Sqlite);
assert_eq!(
leido.container_name.as_deref(),
Some("contenedor-target-1-54")
);
assert_eq!(leido.db_path.as_deref(), Some("/var/lib/kesto/kesto.db"));
assert_eq!(
leido.host_path, None,
"un sqlite no es una fuente de fichero del host"
);
assert_eq!(leido.db_user, None, "un sqlite no tiene usuario de base");
assert!(
leido.db_password.is_none(),
"un sqlite no tiene credencial de base"
);
assert_eq!(leido.db_name, None, "un sqlite no tiene base por nombre");
let almacenado: (String, String) =
sqlx::query_as("SELECT target_type, db_path FROM backup_targets WHERE id = ?1")
.bind(dado_de_alta.id.hyphenated().to_string())
.fetch_one(&pool)
.await
.expect("el tipo y la ruta deben poder leerse");
assert_eq!(
almacenado.0, "sqlite",
"el tipo no se guardó como el texto del enumerado"
);
assert_eq!(
almacenado.1, "/var/lib/kesto/kesto.db",
"el db_path no se guardó tal cual: la ruta no es un secreto y el motor la \
necesita legible"
);
let mut con_otra = leido.clone();
con_otra.db_path = Some("/var/lib/kesto/otra.db".to_owned());
assert!(
targets
.actualizar(&con_otra)
.await
.expect("la actualización no puede fallar"),
"la fila existía, así que la actualización debe decir que sí"
);
let con_la_otra = targets
.leer(&con_otra.id)
.await
.expect("la lectura no puede fallar")
.expect("la fila debe seguir existiendo");
assert_eq!(
con_la_otra.db_path.as_deref(),
Some("/var/lib/kesto/otra.db"),
"la actualización no escribió el db_path nuevo"
);
let mut sin_ruta = con_la_otra.clone();
sin_ruta.db_path = None;
assert!(
targets
.actualizar(&sin_ruta)
.await
.expect("la actualización no puede fallar"),
"la fila existía, así que la actualización debe decir que sí"
);
let limpio = targets
.leer(&sin_ruta.id)
.await
.expect("la lectura no puede fallar")
.expect("la fila debe seguir existiendo");
assert_eq!(
limpio.db_path, None,
"el db_path vaciado no se escribió como NULL"
);
}
#[tokio::test]
async fn borrar_un_target_arrastra_sus_tareas_y_sus_ejecuciones_y_deja_el_repositorio() {
let (_directorio, pool, clave) = base_con_clave().await;
let repositorios = Repositorios::nuevo(pool.clone(), clave.clone());
let targets = Targets::nuevo(pool.clone(), clave);
let tareas = Tareas::nuevo(pool.clone());
let ejecuciones = Ejecuciones::nuevo(pool.clone());
let repositorio = repositorios
.alta(AltaDeRepositorio {
name: "repo-1-32".to_owned(),
endpoint: "https://s3.example.test/repo-1-32".to_owned(),
bucket: "cubo-repo-1-32".to_owned(),
region: "eu-west-1".to_owned(),
access_key_id: "AKIAIOSFODNN7EXAMPLE".to_owned(),
secret_access_key: secreto("AKIAIOSFODNN7EXAMPLE-1-32"),
passphrase: secreto("passphrase-distinguible-1-32"),
})
.await
.expect("el alta del repositorio no puede fallar");
let target = targets
.alta(alta_mariadb("target-1-32"))
.await
.expect("el alta del target no puede fallar");
let mut tareas_del_target = Vec::new();
let mut ejecuciones_de_esas_tareas = Vec::new();
for cron in ["0 3 * * *", "0 4 * * *"] {
let tarea = tareas
.alta(AltaDeTarea {
target_id: target.id,
repository_id: repositorio.id,
cron_expression: cron.to_owned(),
keep_daily: 7,
keep_weekly: 4,
keep_monthly: 12,
enabled: true,
})
.await
.expect("el alta de la tarea no puede fallar");
ejecuciones_de_esas_tareas.push(
ejecuciones
.alta(AltaDeEjecucion {
job_id: tarea.id,
status: Estado::Pending,
snapshot_id: None,
bytes_processed: 0,
bytes_uploaded: 0,
error_message: None,
started_at: OffsetDateTime::now_utc(),
finished_at: None,
})
.await
.expect("el alta de la ejecución no puede fallar")
.id,
);
tareas_del_target.push(tarea.id);
}
assert!(
targets
.borrar(&target.id)
.await
.expect("el borrado no puede fallar"),
"la fila existía, así que el borrado debe decir que sí"
);
let tareas_restantes: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM backup_jobs")
.fetch_one(&pool)
.await
.expect("el recuento de tareas no puede fallar");
assert_eq!(
tareas_restantes, 0,
"las tareas del target sobrevivieron; el `CASCADE` no ocurrió"
);
let ejecuciones_restantes: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM job_executions")
.fetch_one(&pool)
.await
.expect("el recuento de ejecuciones no puede fallar");
assert_eq!(
ejecuciones_restantes, 0,
"las ejecuciones sobrevivieron a sus tareas"
);
for tarea in &tareas_del_target {
assert!(
tareas
.leer(tarea)
.await
.expect("la lectura no puede fallar")
.is_none(),
"la tarea {tarea} sobrevivió al target"
);
}
for ejecucion in &ejecuciones_de_esas_tareas {
assert!(
ejecuciones
.leer(ejecucion)
.await
.expect("la lectura no puede fallar")
.is_none(),
"la ejecución {ejecucion} sobrevivió a su tarea"
);
}
let repositorio_sigue = repositorios
.leer(&repositorio.id)
.await
.expect("la lectura no puede fallar");
assert!(
repositorio_sigue.is_some(),
"el repositorio no debía desaparecer: el que se borró fue el target"
);
}
#[tokio::test]
async fn el_debug_de_un_target_no_publica_la_contrasena() {
let (_directorio, _pool, targets) = capa().await;
let dado_de_alta = targets
.alta(alta_mariadb("target-1-48"))
.await
.expect("el alta no puede fallar");
let leido = targets
.leer(&dado_de_alta.id)
.await
.expect("la lectura no puede fallar")
.expect("la fila debe estar");
let impreso = format!("{leido:?}");
assert!(
!impreso.contains(PASSWORD),
"el `Debug` publicó la contraseña de base: {impreso}"
);
assert!(
impreso.to_lowercase().contains("redact"),
"el `Debug` no dice que la contraseña está redactada: {impreso}"
);
assert_eq!(
leido.db_password.as_ref().map(SecretString::expose),
Some(PASSWORD),
"el valor debe salir por `expose`"
);
}
#[tokio::test]
async fn un_db_password_vacio_se_rechaza_y_el_none_no() {
let (_directorio, pool, targets) = capa().await;
let error = targets
.alta(AltaDeTarget {
db_password: Some(secreto("")),
..alta_mariadb("target-t4-vacio")
})
.await
.expect_err("una contraseña vacía no puede darse de alta");
assert!(
matches!(error, DbError::SecretoVacio { campo } if campo == "db_password"),
"el fallo debe nombrar el campo vacío: {error}"
);
let filas: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM backup_targets")
.fetch_one(&pool)
.await
.expect("el recuento no puede fallar");
assert_eq!(filas, 0, "la fila se escribió pese al secreto vacío");
let directorio = targets
.alta(alta_directorio("target-t4-directorio", "/srv/datos"))
.await
.expect("un target de directorio sin contraseña es legítimo");
assert!(
directorio.db_password.is_none(),
"un directorio se queda sin contraseña, no con una vacía"
);
let mut vacio = directorio.clone();
vacio.db_password = Some(secreto(""));
let error = targets
.actualizar(&vacio)
.await
.expect_err("la actualización no puede guardar una contraseña vacía");
assert!(
matches!(error, DbError::SecretoVacio { campo } if campo == "db_password"),
"el fallo debe nombrar el campo vacío: {error}"
);
}
}