use std::convert::Infallible;
use std::pin::Pin;
use axum::Router;
use axum::extract::State;
use axum::response::sse::{Event, Sse};
use axum::routing::get;
use futures_util::stream::{self, Stream};
use serde::Serialize;
use tokio::sync::broadcast;
use tokio::sync::broadcast::error::RecvError;
use crate::engine::EventoDeEjecucion;
use crate::state::AppState;
pub const NOMBRE_DEL_EVENTO: &str = "execution_progress";
type FlujoDelStream = Pin<Box<dyn Stream<Item = Result<Event, Infallible>> + Send>>;
pub fn router() -> Router<AppState> {
Router::new().route("/api/v1/events", get(stream))
}
pub async fn stream(State(estado): State<AppState>) -> Sse<FlujoDelStream> {
match estado.motor.hub().suscribir() {
Some(receptor) => Sse::new(Box::pin(flujo(receptor))),
None => {
tracing::info!(
"el stream se pide con el hub ya cerrado: se contesta un cuerpo vacío, que termina \
en el acto, en vez de dejar la conexión abierta esperando eventos que no van a \
llegar"
);
Sse::new(Box::pin(stream::empty::<Result<Event, Infallible>>()))
}
}
}
fn flujo(
receptor: broadcast::Receiver<EventoDeEjecucion>,
) -> impl Stream<Item = Result<Event, Infallible>> {
stream::unfold(receptor, |mut receptor| async move {
loop {
match receptor.recv().await {
Ok(evento) => {
if let Some(evento) = linea(&evento) {
return Some((Ok(evento), receptor));
}
}
Err(RecvError::Lagged(eventos_perdidos)) => {
tracing::warn!(
eventos_perdidos,
"un cliente del stream se quedó atrás y perdió eventos: sigue leyendo, \
que el contrato no promete reenvío"
);
}
Err(RecvError::Closed) => return None,
}
}
})
}
fn linea(evento: &EventoDeEjecucion) -> Option<Event> {
match serde_json::to_string(&VistaDelEvento::desde(evento)) {
Ok(datos) => Some(Event::default().event(NOMBRE_DEL_EVENTO).data(datos)),
Err(error) => {
tracing::error!(
%error,
"un aviso de ejecución no se pudo serializar: se descarta y el stream sigue"
);
None
}
}
}
#[derive(Debug, Serialize)]
struct VistaDelEvento<'a> {
execution_id: String,
job_id: String,
status: &'static str,
bytes_processed: u64,
bytes_uploaded: u64,
percentage: Option<f64>,
current_action: &'a str,
}
impl<'a> VistaDelEvento<'a> {
fn desde(evento: &'a EventoDeEjecucion) -> Self {
Self {
execution_id: evento.execution_id.hyphenated().to_string(),
job_id: evento.job_id.hyphenated().to_string(),
status: evento.status.texto(),
bytes_processed: evento.bytes_processed,
bytes_uploaded: evento.bytes_uploaded,
percentage: evento.percentage,
current_action: &evento.current_action,
}
}
}
#[cfg(test)]
mod tests {
use std::path::Path;
use std::time::{Duration, Instant};
use axum::Router;
use axum::body::Body;
use axum::http::StatusCode;
use axum::http::header;
use uuid::Uuid;
use crate::api::api_router;
use crate::test_support::{
DirectorioDePrueba, datos_del_evento, directorio_de_prueba, estado_desarrollo, get,
header_text, post, post_json, router_oidc, siguiente_evento,
};
const PLAZO_DEL_EVENTO: Duration = Duration::from_secs(30);
const LOS_SIETE_CAMPOS_DEL_SRS: [&str; 7] = [
"execution_id",
"job_id",
"status",
"bytes_processed",
"bytes_uploaded",
"percentage",
"current_action",
];
struct AparatoDelStream {
_fuente: DirectorioDePrueba,
_destino: DirectorioDePrueba,
router: Router,
tarea: Uuid,
}
impl AparatoDelStream {
async fn nuevo() -> Self {
let router = api_router(estado_desarrollo().await);
let fuente = directorio_de_prueba();
std::fs::write(
fuente.ruta().join("semilla.txt"),
b"contenido de la fuente\n",
)
.expect("el fichero de la fuente se escribe");
let destino = directorio_de_prueba();
let target = alta_de_target(&router, fuente.ruta()).await;
let repositorio = alta_de_repositorio(&router, destino.ruta()).await;
let tarea = alta_de_tarea(&router, &target, &repositorio).await;
Self {
_fuente: fuente,
_destino: destino,
router,
tarea,
}
}
async fn disparar(&self) -> String {
let uri = format!("/api/v1/jobs/{}/run", self.tarea);
let respuesta = post(self.router.clone(), &uri).await;
let status = respuesta.status();
let cuerpo = crate::test_support::body_json(respuesta).await;
assert_eq!(
status,
StatusCode::ACCEPTED,
"el disparo tiene que contestar 202 para que haya algo que seguir por el stream, y \
contestó {status} con {cuerpo}"
);
cuerpo["data"]["id"]
.as_str()
.expect("el disparo tiene que devolver el identificador de la ejecución")
.to_owned()
}
}
async fn alta_de_target(router: &Router, ruta: &Path) -> String {
let respuesta = post_json(
router.clone(),
"/api/v1/targets",
&serde_json::json!({
"name": "fuente-del-stream",
"target_type": "directory",
"host_path": ruta.to_string_lossy(),
}),
)
.await;
let status = respuesta.status();
let cuerpo = crate::test_support::body_json(respuesta).await;
assert_eq!(
status,
StatusCode::CREATED,
"el target de directorio de la siembra tiene que darse de alta para que el disparo tenga \
algo que copiar, y contestó {status} con {cuerpo}"
);
cuerpo["data"]["id"]
.as_str()
.expect("el alta del target debe devolver su identificador")
.to_owned()
}
async fn alta_de_repositorio(router: &Router, destino: &Path) -> String {
let respuesta = post_json(
router.clone(),
"/api/v1/repositories",
&serde_json::json!({
"name": "repositorio-del-stream",
"endpoint": destino.to_string_lossy(),
"bucket": "cubo-del-stream",
"region": "region-de-prueba",
"access_key_id": "clave-de-acceso-de-prueba",
"secret_access_key": "AKIAIOSFODNN7EXAMPLE-secreto-del-stream",
"passphrase": crate::test_support::PASSPHRASE_DE_PRUEBA,
}),
)
.await;
let status = respuesta.status();
let cuerpo = crate::test_support::body_json(respuesta).await;
assert_eq!(
status,
StatusCode::CREATED,
"el repositorio de la siembra tiene que darse de alta para que el motor tenga dónde \
archivar, y contestó {status} con {cuerpo}"
);
cuerpo["data"]["id"]
.as_str()
.expect("el alta del repositorio debe devolver su identificador")
.to_owned()
}
async fn alta_de_tarea(router: &Router, target_id: &str, repository_id: &str) -> Uuid {
let respuesta = post_json(
router.clone(),
"/api/v1/jobs",
&serde_json::json!({
"target_id": target_id,
"repository_id": repository_id,
"cron_expression": "0 3 * * *",
"keep_daily": 7,
"keep_weekly": 4,
"keep_monthly": 6,
"enabled": true,
}),
)
.await;
let status = respuesta.status();
let cuerpo = crate::test_support::body_json(respuesta).await;
assert_eq!(
status,
StatusCode::CREATED,
"la tarea de la siembra tiene que darse de alta para poder dispararla, y contestó {status} \
con {cuerpo}"
);
let id = cuerpo["data"]["id"]
.as_str()
.expect("el alta de la tarea debe devolver su identificador");
Uuid::parse_str(id)
.expect("el alta de la tarea devuelve un identificador con forma de UUID")
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn el_stream_contesta_su_tipo_de_contenido_y_no_exige_parametros() {
let respuesta = get(router_desarrollo_del_stream().await, "/api/v1/events").await;
let status = respuesta.status();
let tipo = header_text(&respuesta, header::CONTENT_TYPE);
assert_eq!(
status,
StatusCode::OK,
"el stream tiene que atender una petición sin parámetros, y contestó {status} con el tipo \
{tipo:?}"
);
assert!(
tipo.as_deref()
.is_some_and(|tipo| tipo.starts_with("text/event-stream")),
"el tipo de contenido del stream tiene que ser `text/event-stream`, y llegó {tipo:?}"
);
}
async fn router_desarrollo_del_stream() -> Router {
api_router(estado_desarrollo().await)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn el_evento_del_stream_trae_los_siete_campos_del_srs_y_no_lleva_la_envoltura() {
let aparato = AparatoDelStream::nuevo().await;
let respuesta = get(aparato.router.clone(), "/api/v1/events").await;
let status = respuesta.status();
assert_eq!(
status,
StatusCode::OK,
"el stream tiene que abrirse antes de que el motor publique nada, y contestó {status}"
);
let mut cuerpo = respuesta.into_body();
let ejecucion = aparato.disparar().await;
let evento = siguiente_evento(&mut cuerpo, PLAZO_DEL_EVENTO)
.await
.expect(
"el stream no publicó ni un evento después de encolar una ejecución: o el aviso no \
llega al cliente o el cliente no estaba suscrito cuando el motor lo publicó",
);
let nombre = evento
.lines()
.find_map(|linea| linea.strip_prefix("event:"))
.map(str::trim);
assert_eq!(
nombre,
Some("execution_progress"),
"el §5 publica un único nombre de evento, `execution_progress`, y llegó {evento:?}"
);
let datos = datos_del_evento(&evento);
for campo in LOS_SIETE_CAMPOS_DEL_SRS {
assert!(
datos.get(campo).is_some(),
"el `data` del evento no trae el campo `{campo}` del §5: {evento:?}"
);
}
let cuantos = datos
.as_object()
.map(serde_json::Map::len)
.unwrap_or_default();
assert_eq!(
cuantos,
LOS_SIETE_CAMPOS_DEL_SRS.len(),
"el `data` del evento tiene que traer exactamente los siete campos del §5, y trajo \
{cuantos}: {evento:?}"
);
assert!(
datos.get("message").is_none() && datos.get("data").is_none(),
"el cuerpo del stream no puede llevar la envoltura `{{status, message, data}}` dentro del \
`data`: un `text/event-stream` envuelto no es un `text/event-stream`, y llegó {evento:?}"
);
assert_eq!(
datos["execution_id"], ejecucion,
"el aviso tiene que hablar de la ejecución que se acaba de encolar: {evento:?}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn dos_clientes_del_stream_ven_el_mismo_evento() {
let aparato = AparatoDelStream::nuevo().await;
let primer_cliente = get(aparato.router.clone(), "/api/v1/events").await;
let segundo_cliente = get(aparato.router.clone(), "/api/v1/events").await;
assert_eq!(
(primer_cliente.status(), segundo_cliente.status()),
(StatusCode::OK, StatusCode::OK),
"los dos clientes tienen que poder abrir el stream antes del disparo"
);
let mut primero = primer_cliente.into_body();
let mut segundo = segundo_cliente.into_body();
let ejecucion = aparato.disparar().await;
let de_uno = siguiente_evento(&mut primero, PLAZO_DEL_EVENTO)
.await
.expect("el primer cliente no recibió ningún evento");
let de_otro = siguiente_evento(&mut segundo, PLAZO_DEL_EVENTO)
.await
.expect("el segundo cliente no recibió ningún evento");
let datos_uno = datos_del_evento(&de_uno);
let datos_otro = datos_del_evento(&de_otro);
assert_eq!(
datos_uno, datos_otro,
"dos clientes conectados a la vez recibieron eventos distintos: el primero {de_uno:?} y el \
segundo {de_otro:?}"
);
assert_eq!(
datos_uno["execution_id"], ejecucion,
"el primer evento de los dos clientes no habla de la ejecución que se acaba de encolar: \
{de_uno:?}"
);
assert!(
espera_el_nacimiento(datos_uno, &mut primero, &ejecucion, PLAZO_DEL_EVENTO).await,
"el stream no anunció el nacimiento de {ejecucion} en {PLAZO_DEL_EVENTO:?}: la ejecución \
que se acaba de encolar no se vio nunca en `pending`, así que el panel no podría pintar \
que ha nacido"
);
}
async fn espera_el_nacimiento(
primer_evento: serde_json::Value,
cuerpo: &mut Body,
ejecucion: &str,
plazo: Duration,
) -> bool {
let limite = Instant::now() + plazo;
let mut datos = primer_evento;
loop {
let es_el_nacimiento =
datos["execution_id"].as_str() == Some(ejecucion) && datos["status"] == "pending";
if es_el_nacimiento {
return true;
}
let restante = limite.saturating_duration_since(Instant::now());
match siguiente_evento(cuerpo, restante).await {
Some(evento) => datos = datos_del_evento(&evento),
None => return false,
}
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn sin_sesion_el_stream_contesta_401_con_la_envoltura() {
let respuesta = get(router_oidc().await, "/api/v1/events").await;
let status = respuesta.status();
assert_eq!(
status,
StatusCode::UNAUTHORIZED,
"el stream es superficie de negocio y en modo OIDC tiene que exigir sesión: contestó \
{status}"
);
let texto = crate::test_support::body_text(respuesta).await;
let cuerpo: serde_json::Value = serde_json::from_str(&texto)
.expect("el 401 del stream tiene que salir por la envoltura JSON de la API");
assert_eq!(cuerpo["status"], 401, "la envoltura del 401: {texto}");
assert!(
cuerpo["message"]
.as_str()
.is_some_and(|mensaje| !mensaje.is_empty()),
"el 401 tiene que traer un mensaje: {texto}"
);
}
}