use std::fs;
use std::path::{Path, PathBuf};
const DIRECTORIO_WORKFLOWS: &str = ".github/workflows";
const EXTENSIONES: [&str; 2] = ["yml", "yaml"];
const SCRIPT: &str = ".github/scripts/smoke.sh";
const DEBEN_INVOCAR: [&str; 2] = [".github/workflows/ci.yml", ".github/workflows/release.yml"];
const AYUDANTES: [&str; 3] = ["comprobar(", "cabecera(", "fallo "];
const ARRANQUE_PROPIO: [&str; 2] = ["--detach", "/healthz"];
const CHECK_SH: &str = "examples/traefik/check.sh";
const CASO_502: &str = "502";
const MOTIVO_502: &str = "rechaza";
fn descubrir_workflows() -> Vec<String> {
let raiz = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let mut encontrados = Vec::new();
recorrer(&raiz.join(DIRECTORIO_WORKFLOWS), &raiz, &mut encontrados);
encontrados.sort();
encontrados
}
fn recorrer(dir: &Path, raiz: &Path, encontrados: &mut Vec<String>) {
let Ok(entradas) = fs::read_dir(dir) else {
return;
};
for entrada in entradas.flatten() {
let ruta = entrada.path();
if ruta.is_dir() {
recorrer(&ruta, raiz, encontrados);
continue;
}
let extension = ruta
.extension()
.and_then(|e| e.to_str())
.unwrap_or_default();
if !EXTENSIONES.contains(&extension) {
continue;
}
if let Ok(relativa) = ruta.strip_prefix(raiz) {
encontrados.push(relativa.to_string_lossy().replace('\\', "/"));
}
}
}
fn es_comentario(linea: &str) -> bool {
linea.trim_start().starts_with('#')
}
fn primera_linea_de_codigo_con(texto: &str, aguja: &str) -> Option<usize> {
texto
.lines()
.enumerate()
.filter(|(_, linea)| !es_comentario(linea))
.find(|(_, linea)| linea.contains(aguja))
.map(|(indice, _)| indice + 1)
}
fn hay_en_codigo(texto: &str, aguja: &str) -> bool {
primera_linea_de_codigo_con(texto, aguja).is_some()
}
fn aserciones_propias(fichero: &str, texto: &str) -> Vec<String> {
let mut fallos = Vec::new();
for ayudante in AYUDANTES {
if let Some(linea) = primera_linea_de_codigo_con(texto, ayudante) {
fallos.push(format!(
"{fichero}:{linea} — lleva el ayudante {ayudante:?} del smoke test; \
las aserciones sobre la imagen arrancada viven solo en {SCRIPT}"
));
}
}
if ARRANQUE_PROPIO
.iter()
.all(|aguja| hay_en_codigo(texto, aguja))
{
let linea = primera_linea_de_codigo_con(texto, ARRANQUE_PROPIO[0]).unwrap_or(1);
fallos.push(format!(
"{fichero}:{linea} — lleva su propio bucle de arranque (`{}` junto a `{}`); \
solo {SCRIPT} arranca el contenedor",
ARRANQUE_PROPIO[0], ARRANQUE_PROPIO[1]
));
}
fallos
}
fn invoca_el_script(texto: &str) -> bool {
texto.contains(SCRIPT)
}
fn fallos_del_script() -> Vec<String> {
let ruta = Path::new(env!("CARGO_MANIFEST_DIR")).join(SCRIPT);
match fs::read_to_string(&ruta) {
Ok(_) => Vec::new(),
Err(error) => vec![format!(
"{SCRIPT} — el artefacto único de las aserciones sobre la imagen arrancada \
no se pudo leer ({error}); un workflow que invoca un fichero que no está \
es una invocación que no prueba nada"
)],
}
}
fn fallos_de_invocacion(leidos: &[(String, String)]) -> Vec<String> {
let mut fallos = Vec::new();
for exigido in DEBEN_INVOCAR {
match leidos.iter().find(|(fichero, _)| fichero == exigido) {
None => fallos.push(format!(
"{exigido} — no se descubrió en {DIRECTORIO_WORKFLOWS}; los workflows \
que deben probar la imagen han de existir e invocar {SCRIPT}"
)),
Some((_, texto)) => {
if !invoca_el_script(texto) {
fallos.push(format!(
"{exigido} — no invoca {SCRIPT}: las aserciones sobre la imagen \
arrancada han de vivir solo en ese script"
));
}
}
}
}
fallos
}
fn fallos_del_caso_502(texto: &str) -> Vec<String> {
let mut fallos = Vec::new();
let tiene_caso = texto
.lines()
.filter(|linea| !linea.trim_start().starts_with('#'))
.any(|linea| linea.split_whitespace().any(|token| token == CASO_502));
if !tiene_caso {
fallos.push(format!(
"{CHECK_SH} — no hay ninguna línea que no sea un comentario con `{CASO_502}` \
como token propio; el caso del upstream que rechaza la conexión (Traefik 502 \
con la página del servicio) ha desaparecido"
));
}
if !texto.contains(MOTIVO_502) {
fallos.push(format!(
"{CHECK_SH} — no conserva la palabra {MOTIVO_502:?}, que documenta por qué el \
upstream ha de rechazar la conexión en vez de pararse (un servicio sin \
servidores da 503, no 502)"
));
}
fallos
}
fn exigir_sin_fallos(motivo: &str, fallos: &[String]) {
assert!(
fallos.is_empty(),
"{motivo} ({} incumplimiento(s)):\n- {}",
fallos.len(),
fallos.join("\n- ")
);
}
#[test]
fn cada_workflow_que_prueba_la_imagen_invoca_el_smoke_test() {
let mut leidos: Vec<(String, String)> = Vec::new();
let mut fallos = fallos_del_script();
for fichero in descubrir_workflows() {
let ruta = Path::new(env!("CARGO_MANIFEST_DIR")).join(&fichero);
match fs::read_to_string(&ruta) {
Ok(texto) => leidos.push((fichero, texto)),
Err(error) => fallos.push(format!("no se pudo leer {fichero}: {error}")),
}
}
fallos.extend(fallos_de_invocacion(&leidos));
exigir_sin_fallos(
"los workflows que prueban la imagen deben invocar el artefacto único con las aserciones",
&fallos,
);
}
#[test]
fn ningun_workflow_lleva_las_aserciones_del_smoke_test() {
let mut fallos = Vec::new();
for fichero in descubrir_workflows() {
let ruta = Path::new(env!("CARGO_MANIFEST_DIR")).join(&fichero);
match fs::read_to_string(&ruta) {
Ok(texto) => fallos.extend(aserciones_propias(&fichero, &texto)),
Err(error) => fallos.push(format!("no se pudo leer {fichero}: {error}")),
}
}
exigir_sin_fallos(
"ningún workflow debe llevar sus propias aserciones sobre la imagen arrancada",
&fallos,
);
}
#[test]
fn el_caso_del_502_sigue_en_check_sh() {
let mut fallos = Vec::new();
let ruta = Path::new(env!("CARGO_MANIFEST_DIR")).join(CHECK_SH);
match fs::read_to_string(&ruta) {
Ok(texto) => fallos.extend(fallos_del_caso_502(&texto)),
Err(error) => fallos.push(format!("no se pudo leer {CHECK_SH}: {error}")),
}
exigir_sin_fallos(
"el script del ejemplo debe conservar el caso del upstream que rechaza la conexión (502)",
&fallos,
);
}
#[test]
fn r1_un_workflow_con_sus_propias_aserciones_incumple() {
let texto = "comprobar() {\n real=$(curl -s -o /dev/null -w '%{http_code}' \"$base$1\")\n}\n\
comprobar /healthz 200\nfallo \"el contenedor no respondió en /healthz\"\n";
let fallos = aserciones_propias("sintetico.yml", texto);
assert!(
!fallos.is_empty(),
"un workflow con sus propias aserciones debe incumplir"
);
assert!(
fallos
.iter()
.any(|fallo| fallo.contains("sintetico.yml") && fallo.contains("comprobar(")),
"algún fallo debe nombrar el fichero y el ayudante `comprobar(`: {fallos:?}"
);
}
#[test]
fn r2_un_workflow_que_invoca_el_script_y_no_tiene_aserciones_cumple() {
let texto =
" - name: Smoke test\n run: |\n bash .github/scripts/smoke.sh\n";
assert!(
invoca_el_script(texto),
"el path del script debe reconocerse como invocación"
);
assert!(
aserciones_propias("sintetico.yml", texto).is_empty(),
"un workflow que solo invoca el script no lleva aserciones propias"
);
}
#[test]
fn r3_un_curl_suelto_no_es_incumplimiento() {
let texto = " codigo=$(curl -s -o /dev/null -w '%{http_code}' \\\n\
\x20 \"https://ghcr.io/token?scope=repository:$REPO:pull\")\n";
assert!(
aserciones_propias(".github/workflows/release.yml", texto).is_empty(),
"un curl contra la API de GHCR no es una aserción sobre el servicio"
);
}
#[test]
fn r4_un_bucle_de_arranque_propio_incumple() {
let texto = "docker run --detach --rm --name smoke imagen:tag\n\
for i in $(seq 1 30); do\n\
\x20 if curl -fsS -o /dev/null \"$base/healthz\"; then break; fi\n\
\x20 sleep 1\n\
done\n";
let fallos = aserciones_propias("sintetico.yml", texto);
assert!(
!fallos.is_empty(),
"un workflow que arranca el contenedor debe incumplir"
);
assert!(
fallos
.iter()
.any(|fallo| fallo.contains("bucle de arranque")),
"el fallo debe decir que el workflow lleva su propio bucle de arranque: {fallos:?}"
);
}
#[test]
fn r5_un_check_sh_sin_el_caso_del_502_incumple() {
let sin_caso = "#!/usr/bin/env sh\nset -eu\n\
code app.localtest.me /boom 503 \"el 503 del backend\"\n";
let fallos = fallos_del_caso_502(sin_caso);
assert!(
!fallos.is_empty(),
"un check.sh sin el caso del 502 debe incumplir"
);
assert!(
fallos.iter().any(|fallo| fallo.contains(CHECK_SH)),
"el fallo debe nombrar la ruta del script: {fallos:?}"
);
let con_caso = "# nada\ncode caido.localtest.me /x 502 \"el upstream rechaza la conexion\"\n";
assert!(
fallos_del_caso_502(con_caso).is_empty(),
"un caso 502 real con la palabra `rechaza` cumple"
);
let solo_comentario =
"# code caido.localtest.me /x 502 \"el upstream rechaza la conexion\"\nset -eu\n";
assert!(
!fallos_del_caso_502(solo_comentario).is_empty(),
"un 502 que solo aparece en un comentario no es un caso"
);
}
#[test]
fn r6_la_palabra_fallo_de_un_comentario_no_es_el_ayudante() {
let texto = " # Sin caché a propósito: lo que se comprueba es que la imagen se construye\n # **desde el árbol**, en frío, como en un host cualquiera. Una caché de\n # capas taparía justo el fallo que esta puerta viene a cazar —un `COPY` que\n";
assert!(
aserciones_propias("sintetico.yml", texto).is_empty(),
"la palabra «fallo » de un comentario en prosa no es el ayudante del smoke test: es prosa castellana, y marcarla sería un rojo falso"
);
let codigo = " || fallo \"el contenedor no respondió\"\n";
assert!(
aserciones_propias("sintetico.yml", codigo)
.iter()
.any(|fallo| fallo.contains("fallo ")),
"la misma palabra en una línea que no es un comentario sí cuenta: {codigo:?}"
);
}