diff --git a/docs/plans/EXECPLAN_ci_shell_resilience.md b/docs/plans/EXECPLAN_ci_shell_resilience.md index 79e990c9..2543df73 100644 --- a/docs/plans/EXECPLAN_ci_shell_resilience.md +++ b/docs/plans/EXECPLAN_ci_shell_resilience.md @@ -16,23 +16,28 @@ Queremos que los desarrolladores puedan ejecutar los scripts `scripts/ci/*.sh` s - [x] (2025-11-16 11:45Z) Normalizar `scripts/ci/infrastructure/validate-config.sh` y los hooks de `scripts/git-hooks/*.sh` (permisos + sintaxis) para que pasen la validación de scripts. - [x] (2025-11-16 11:55Z) Confirmar que `scripts/ci/run-all-checks.sh` termina con código 0 y reporta secciones con PASS/SKIP según corresponda. - [x] (2025-11-16 12:05Z) Documentar hallazgos y resultados en las secciones vivas antes de cerrar la ExecPlan. +- [x] (2025-11-16 12:40Z) Revisar `bandit-scan.sh`, `npm-audit.sh` y `test-execution-time.sh` para eliminar dependencias implícitas en GitHub Actions (instalaciones automáticas, pipelines con `tee` sin `pipefail`). +- [x] (2025-11-16 12:42Z) Ampliar las pruebas de `scripts/tests/test_ci_shell_scripts.py` cubriendo los degradados esperados para Bandit, npm y el test pyramid en entornos sin dependencias. ## Surprises & Discoveries - Observación: la especificación OpenAPI (`docs/api/openapi_permisos.yaml`) contenía descripciones sin comillas con dos puntos, lo que rompía el parser YAML. Se resolvió citando los literales problemáticos. - Observación: `.devcontainer/devcontainer.json` incluye comentarios estilo JavaScript; el validador JSON se detuvo hasta que se agregó una lista de exclusión controlada. - Observación: algunos workflows (`requirements_validate_traceability.yml`) se generan dinámicamente y no son YAML puro, de modo que se marcaron como `skip` para evitar falsos positivos. +- Observación: `npm audit` requiere `package-lock.json` y acceso al registro; en entornos locales sin dependencias instaladas o sin red es preferible degradar a `skip` para no bloquear desarrolladores. ## Decision Log - Decisión: Tratar los chequeos que dependen de Django/Bandit como "skip" cuando las dependencias no estén presentes. Rationale: el entorno local y de CI desconectado no puede instalarlas, pero necesitamos que el pipeline continúe. Fecha: 2025-11-16 / Autor: Codex. - Decisión: Excluir `.devcontainer/devcontainer.json` y `requirements_validate_traceability.yml` de la validación estricta (registrando advertencias). Rationale: ambos archivos usan sintaxis extendida intencional (comentarios y plantillas) y romperían la verificación en frío. Fecha: 2025-11-16 / Autor: Codex. +- Decisión: No intentar instalaciones automáticas (`pip install bandit`, `npm audit fix`) dentro de los scripts; en su lugar, degradar con mensajes accionables cuando la CLI o la red no estén disponibles. Rationale: evitar bloqueos en entornos air-gapped y mantener tiempos de ejecución acotados. Fecha: 2025-11-16 / Autor: Codex. ## Outcomes & Retrospective - `scripts/ci/run-all-checks.sh` ahora finaliza con código 0 en entornos sin Django/Bandit, marcando seis chequeos como SKIP y manteniendo el reporte final completo. - Los scripts de infraestructura y seguridad reportan advertencias claras ("Skipping Django checks", "Bandit installation failed - skipping") en lugar de stack traces, mejorando la depuración local. - Las pruebas `pytest scripts/tests/test_ci_shell_scripts.py` verifican el nuevo flujo (3/3 en 26.9s) confirmando el comportamiento degradado. +- Los scripts de seguridad y validación del test pyramid ahora detectan la ausencia de `bandit`, `npm`, Django o la red antes de ejecutar comandos costosos, retornando `SKIP` en segundos y evitando depender de GitHub Actions para descubrir estos casos. ## Context and Orientation @@ -67,6 +72,9 @@ Esto provoca que la suite de infraestructura registre múltiples FAIL en cascada 6. Cambiar `scripts/ci/security/bandit-scan.sh` de modo que si Bandit no está disponible y la instalación falla, se registre un skip en vez de un fail. 7. Revisar `scripts/ci/infrastructure/validate-config.sh` para que el chequeo de settings degrade a skip cuando falte Django, manteniendo las validaciones de JSON/YAML. 8. Ejecutar `scripts/ci/run-all-checks.sh` y las pruebas unitarias para verificar que el pipeline regresa exit 0 y que los mensajes de skip están presentes. +9. Ajustar `scripts/ci/security/npm-audit.sh` para detectar el frontend bajo `ui/`, degradar a skip cuando `npm` no esté disponible o la red falle y evitar `npm audit fix` en entornos locales. +10. Simplificar `scripts/ci/security/bandit-scan.sh` para que omita instalaciones automáticas y degrade inmediatamente si la CLI no está presente. +11. Incorporar una guardia en `scripts/ci/testing/test-execution-time.sh` (Django + pytest) y habilitar `set -o pipefail` para evitar falsos positivos al canalizar la salida. ## Concrete Steps diff --git a/scripts/ci/infrastructure/health-check.sh b/scripts/ci/infrastructure/health-check.sh index d5079896..68aaaca3 100755 --- a/scripts/ci/infrastructure/health-check.sh +++ b/scripts/ci/infrastructure/health-check.sh @@ -53,7 +53,18 @@ fi log_info "Checking database connectivity..." cd "$PROJECT_ROOT/api/callcentersite" -if [ -f "manage.py" ]; then +DJANGO_READY=true +if ! python3 -c "import django" >/dev/null 2>&1; then + DJANGO_READY=false + log_warn "Skipping Django checks: Django is not installed in the current environment" + CHECKS_SKIPPED=$((CHECKS_SKIPPED + 1)) +elif [ ! -f "manage.py" ]; then + DJANGO_READY=false + log_warn "Skipping Django checks: manage.py not found" + CHECKS_SKIPPED=$((CHECKS_SKIPPED + 1)) +fi + +if [ "$DJANGO_READY" = true ]; then if DB_CHECK_OUTPUT=$(python3 manage.py check --database default 2>&1); then log_info "Database connectivity: OK" CHECKS_PASSED=$((CHECKS_PASSED + 1)) @@ -65,17 +76,19 @@ if [ -f "manage.py" ]; then CHECKS_FAILED=$((CHECKS_FAILED + 1)) fi -# Check 4: Django configuration -log_info "Checking Django configuration..." -if DJANGO_CHECK_OUTPUT=$(python3 manage.py check 2>&1); then - log_info "Django configuration: OK" - CHECKS_PASSED=$((CHECKS_PASSED + 1)) + log_info "Checking Django configuration..." + if DJANGO_CHECK_OUTPUT=$(python3 manage.py check 2>&1); then + log_info "Django configuration: OK" + CHECKS_PASSED=$((CHECKS_PASSED + 1)) + else + log_error "Django configuration check failed" + echo "$DJANGO_CHECK_OUTPUT" | tail -n 20 | while IFS= read -r line; do + log_error " $line" + done + CHECKS_FAILED=$((CHECKS_FAILED + 1)) + fi else - log_error "Django configuration check failed" - echo "$DJANGO_CHECK_OUTPUT" | tail -n 20 | while IFS= read -r line; do - log_error " $line" - done - CHECKS_FAILED=$((CHECKS_FAILED + 1)) + log_warn "Skipping Django checks due to missing prerequisites" fi # Check 5: Required directories exist diff --git a/scripts/ci/run-all-checks.sh b/scripts/ci/run-all-checks.sh index 9099a5ba..c0bc4b50 100755 --- a/scripts/ci/run-all-checks.sh +++ b/scripts/ci/run-all-checks.sh @@ -11,8 +11,7 @@ # 0 - All checks passed # 1 - One or more checks failed -set -u -set -o pipefail +set -euo pipefail RED='\033[0;31m' GREEN='\033[0;32m' diff --git a/scripts/ci/security/bandit-scan.sh b/scripts/ci/security/bandit-scan.sh index 07d372ce..2d601ed1 100755 --- a/scripts/ci/security/bandit-scan.sh +++ b/scripts/ci/security/bandit-scan.sh @@ -7,8 +7,9 @@ # Exit codes: # 0 - No security issues # 1 - Security issues found +# 2 - Scan skipped (bandit not available) -set -e +set -euo pipefail RED='\033[0;31m' GREEN='\033[0;32m' @@ -26,23 +27,17 @@ log_info "Running Bandit security scan..." # Activar entorno virtual si existe if [ -f "$PROJECT_ROOT/venv/bin/activate" ]; then + # shellcheck source=/dev/null source "$PROJECT_ROOT/venv/bin/activate" elif [ -f "$PROJECT_ROOT/.venv/bin/activate" ]; then + # shellcheck source=/dev/null source "$PROJECT_ROOT/.venv/bin/activate" fi # Check if bandit is installed -if ! command -v bandit &> /dev/null; then - log_warn "Bandit not installed, attempting installation..." - if ! pip install bandit >/tmp/bandit_install.log 2>&1; then - log_warn "Bandit installation failed - skipping scan" - log_warn "Installer output:\n$(tail -n 5 /tmp/bandit_install.log)" - exit 2 - fi -fi - -if ! command -v bandit &> /dev/null; then - log_warn "Bandit still unavailable after installation attempt - skipping scan" +if ! command -v bandit >/dev/null 2>&1; then + log_warn "Bandit CLI not detected - skipping scan" + log_warn "Install bandit locally to run this check (pip install bandit)" exit 2 fi diff --git a/scripts/ci/security/npm-audit.sh b/scripts/ci/security/npm-audit.sh index 2326be05..58e93caa 100755 --- a/scripts/ci/security/npm-audit.sh +++ b/scripts/ci/security/npm-audit.sh @@ -7,9 +7,9 @@ # Exit codes: # 0 - No vulnerabilities # 1 - Vulnerabilities found -# 2 - NPM not used (skip) +# 2 - NPM not used or prerequisites missing (skip) -set -e +set -euo pipefail RED='\033[0;31m' GREEN='\033[0;32m' @@ -25,39 +25,59 @@ PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" log_info "Running NPM security audit..." -# Check if package.json exists -PACKAGE_JSON_FOUND=false +# Locate package.json (support monorepos) +AUDIT_DIR="" if [ -f "$PROJECT_ROOT/package.json" ]; then - PACKAGE_JSON_FOUND=true + AUDIT_DIR="$PROJECT_ROOT" + log_info "Found package.json in repository root" +elif [ -f "$PROJECT_ROOT/ui/package.json" ]; then + AUDIT_DIR="$PROJECT_ROOT/ui" + log_info "Found package.json in ui" elif [ -f "$PROJECT_ROOT/frontend/package.json" ]; then - cd "$PROJECT_ROOT/frontend" - PACKAGE_JSON_FOUND=true + AUDIT_DIR="$PROJECT_ROOT/frontend" + log_info "Found package.json in frontend" fi -if [ "$PACKAGE_JSON_FOUND" = false ]; then +if [ -z "$AUDIT_DIR" ]; then log_warn "No package.json found - skipping NPM audit" exit 2 fi # Check if npm is installed -if ! command -v npm &> /dev/null; then - log_error "npm not installed" - exit 1 +if ! command -v npm >/dev/null 2>&1; then + log_warn "npm CLI not available - skipping audit" + log_warn "Install Node.js/npm locally to run this check" + exit 2 fi log_info "Running npm audit..." -# Run npm audit -if npm audit --audit-level=moderate 2>&1 | tee /tmp/npm_audit.log; then +cd "$AUDIT_DIR" + +AUDIT_LOG="/tmp/npm_audit.log" + +if npm audit --audit-level=moderate >"$AUDIT_LOG" 2>&1; then log_info "NPM audit passed - no vulnerabilities found" exit 0 -else - log_error "NPM audit found vulnerabilities" - cat /tmp/npm_audit.log +fi - log_info "Attempting to fix vulnerabilities..." - npm audit fix +if grep -E "(ENOTFOUND|ECONN|EAI_AGAIN|ENETUNREACH|network request failed)" "$AUDIT_LOG" >/dev/null 2>&1; then + log_warn "npm audit could not reach the registry - skipping (offline environment)" + tail -n 5 "$AUDIT_LOG" | while IFS= read -r line; do + log_warn " $line" + done + exit 2 +fi - exit 1 +if grep -E "(ENOLOCK|requires a lockfile)" "$AUDIT_LOG" >/dev/null 2>&1; then + log_warn "npm audit requires dependencies installed (missing lockfile) - skipping" + tail -n 5 "$AUDIT_LOG" | while IFS= read -r line; do + log_warn " $line" + done + exit 2 fi + +log_error "NPM audit found vulnerabilities or failed unexpectedly" +cat "$AUDIT_LOG" +exit 1 diff --git a/scripts/ci/testing/test-execution-time.sh b/scripts/ci/testing/test-execution-time.sh index e80260c6..dfb2b541 100755 --- a/scripts/ci/testing/test-execution-time.sh +++ b/scripts/ci/testing/test-execution-time.sh @@ -6,9 +6,10 @@ # # Exit codes: # 0 - Test execution time is acceptable -# 1 - Tests are too slow +# 1 - Tests are too slow or failed +# 2 - Prerequisites missing (skip) -set -e +set -euo pipefail RED='\033[0;31m' GREEN='\033[0;32m' @@ -28,11 +29,24 @@ cd "$PROJECT_ROOT/api/callcentersite" # Activar entorno virtual if [ -f "$PROJECT_ROOT/venv/bin/activate" ]; then + # shellcheck source=/dev/null source "$PROJECT_ROOT/venv/bin/activate" elif [ -f "$PROJECT_ROOT/.venv/bin/activate" ]; then + # shellcheck source=/dev/null source "$PROJECT_ROOT/.venv/bin/activate" fi +# Verificar dependencias mínimas +if ! python3 -c "import django" >/dev/null 2>&1; then + log_warn "Skipping test execution time validation: Django not installed" + exit 2 +fi + +if ! command -v pytest >/dev/null 2>&1; then + log_warn "Skipping test execution time validation: pytest CLI not available" + exit 2 +fi + # Run tests with timing log_info "Running tests with timing analysis..." diff --git a/scripts/tests/test_ci_shell_scripts.py b/scripts/tests/test_ci_shell_scripts.py index 14d966d1..877654eb 100644 --- a/scripts/tests/test_ci_shell_scripts.py +++ b/scripts/tests/test_ci_shell_scripts.py @@ -1,32 +1,83 @@ +import os import subprocess from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[2] RUN_ALL_CHECKS = REPO_ROOT / "scripts" / "ci" / "run-all-checks.sh" HEALTH_CHECK = REPO_ROOT / "scripts" / "ci" / "infrastructure" / "health-check.sh" +BANDIT_SCAN = REPO_ROOT / "scripts" / "ci" / "security" / "bandit-scan.sh" +NPM_AUDIT = REPO_ROOT / "scripts" / "ci" / "security" / "npm-audit.sh" +TEST_EXECUTION_TIME = REPO_ROOT / "scripts" / "ci" / "testing" / "test-execution-time.sh" -def _run_script(script_path, *args): +def _run_script(script_path, *args, env=None, timeout=60): return subprocess.run( ["bash", str(script_path), *args], cwd=REPO_ROOT, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + env=env, + timeout=timeout, ) -def test_run_all_checks_reports_summary_even_on_failure(): +def test_run_all_checks_reports_summary_with_skips(): result = _run_script(RUN_ALL_CHECKS) - assert result.returncode != 0, "Expected the aggregated checks to fail in the default dev environment" - combined_output = f"{result.stdout}\\n{result.stderr}" + assert result.returncode == 0, "Aggregated checks should degrade to success with skips locally" + combined_output = f"{result.stdout}\n{result.stderr}" assert "FINAL CI/CD REPORT" in combined_output + assert "Skipped:" in combined_output + assert "[SKIP]" in combined_output or "Skipped: 0" in combined_output -def test_health_check_surfaces_underlying_error_details(): +def test_health_check_degrades_gracefully_when_django_missing(): result = _run_script(HEALTH_CHECK) - assert result.returncode != 0, "The health check should fail when dependencies are missing" - combined_output = f"{result.stdout}\\n{result.stderr}" - assert "ModuleNotFoundError" in combined_output or "No module named" in combined_output + assert result.returncode in (0, 2), "Health check should pass or skip when Django is unavailable" + combined_output = f"{result.stdout}\n{result.stderr}" + assert "Skipping Django checks" in combined_output + + +def test_run_all_checks_sets_strict_shell_flags(): + contents = RUN_ALL_CHECKS.read_text() + + assert "set -euo pipefail" in contents, "run-all-checks.sh must opt into strict shell error handling" + + +def test_bandit_scan_skips_quickly_when_cli_missing(): + result = _run_script(BANDIT_SCAN, timeout=5) + + assert result.returncode == 2, "Bandit scan should degrade to SKIP without attempting long installations" + combined_output = f"{result.stdout}\n{result.stderr}" + assert "Bandit CLI not detected" in combined_output or "Bandit not installed" in combined_output + + +def test_npm_audit_detects_ui_workspace_and_skips_without_npm(): + env = os.environ.copy() + env["PATH"] = ":".join( + [ + "/usr/local/sbin", + "/usr/local/bin", + "/usr/sbin", + "/usr/bin", + "/sbin", + "/bin", + ] + ) + + result = _run_script(NPM_AUDIT, env=env, timeout=30) + + assert result.returncode == 2, "NPM audit should skip when npm CLI is not available" + combined_output = f"{result.stdout}\n{result.stderr}" + assert "Found package.json in ui" in combined_output + assert "npm CLI not available" in combined_output or "npm not installed" in combined_output + + +def test_test_execution_time_skips_without_django(): + result = _run_script(TEST_EXECUTION_TIME, timeout=20) + + assert result.returncode == 2, "Test execution validation should skip when Django is absent" + combined_output = f"{result.stdout}\n{result.stderr}" + assert "Skipping test execution time validation" in combined_output