Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion Backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,17 @@ select = ["E", "F", "I", "N", "W"]
[tool.ruff.lint.per-file-ignores]
# alerts.py usa los nombres de campo exactos del JSON de Alertmanager (camelCase obligatorio)
"routers/alerts.py" = ["N815"]
# seed_chromadb.py: textos largos de runbooks (E501) e import después de load_dotenv() intencional (E402)
# seed scripts: textos largos de runbooks (E501) e import después de load_dotenv() intencional (E402)
"scripts/seed_chromadb.py" = ["E501", "E402"]
"scripts/seed_postgres_runbooks.py" = ["E501", "E402"]
"scripts/seed_podman_runbooks.py" = ["E501", "E402"]
# Docstrings y prompts del LLM con líneas ligeramente largas
"services/alert_processor.py" = ["E501"]
"services/langgraph_engine.py" = ["E501"]
# Agentes multidominio: líneas largas en logging/prompts y orden de imports propio
"services/agents/docker/agent.py" = ["E501", "I001"]
"services/agents/podman/agent.py" = ["E501", "I001"]
"services/agents/postgres/agent.py" = ["E501", "I001"]
"services/agents/supervisor.py" = ["E501", "I001"]
"routers/actions.py" = ["E501"]
"tests/test_smoke.py" = ["E402"]
3 changes: 3 additions & 0 deletions Backend/requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
pytest>=8.0.0
pytest-cov>=5.0.0
httpx>=0.27.0
141 changes: 92 additions & 49 deletions Backend/routers/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,41 @@ def _validate_pg_command(command: str) -> tuple[str, str]:

# ── Ejecutores ────────────────────────────────────────────────────────────────

def _run_podman_command(subcommand: str, container_name: str) -> tuple[int, str, str]:
"""
Ejecuta restart o logs sobre un contenedor Podman usando el Docker SDK
apuntando al socket rootless de Podman (PODMAN_HOST).
No requiere el binario 'podman' en el sistema.
"""
podman_host = os.getenv("PODMAN_HOST", "unix:///run/podman/podman.sock")
try:
import docker # type: ignore
client = docker.DockerClient(base_url=podman_host, timeout=15)
except Exception as e:
return 1, "", f"No se pudo inicializar cliente Podman: {e}"

try:
container = client.containers.get(container_name)
except docker.errors.NotFound:
return 1, "", f"Contenedor '{container_name}' no encontrado en Podman"
except Exception as e:
return 1, "", f"Error al obtener contenedor '{container_name}': {e}"

try:
if subcommand == "restart":
container.restart(timeout=10)
return 0, f"Contenedor '{container_name}' reiniciado correctamente", ""
elif subcommand == "logs":
raw = container.logs(tail=100, timestamps=True)
return 0, raw.decode("utf-8", errors="replace"), ""
else:
return 1, "", f"Subcomando Podman no soportado: '{subcommand}'"
except Exception as e:
return 1, "", f"Error ejecutando podman {subcommand} {container_name}: {e}"
finally:
client.close()


def _run_pg_command(func: str, datname: str) -> tuple[int, str, str]:
"""
Ejecuta la función Postgres segura contra la BD indicada.
Expand All @@ -104,8 +139,8 @@ def _run_pg_command(func: str, datname: str) -> tuple[int, str, str]:
- pg_terminate_backend → pg_terminate_backend() sobre todas las conexiones
"""
try:
import psycopg2 # type: ignore
import psycopg2.extras # type: ignore
import psycopg2 # type: ignore
import psycopg2.extras # type: ignore
except ImportError:
return 1, "", "psycopg2 no está instalado en el backend"

Expand Down Expand Up @@ -273,55 +308,63 @@ async def execute_action(

# ── Rama Docker / Podman ─────────────────────────────────────────────────
command_tokens = _validate_container_command(requested_command)
runtime_bin, subcommand, container_name = command_tokens

if runtime_bin == "podman":
# Podman: usar Docker SDK apuntando al socket rootless (no hay binario podman en el backend)
logger.info(f"[actions] Ejecutando via SDK Podman: {subcommand} {container_name}")
exit_code, stdout, stderr = _run_podman_command(subcommand, container_name)
else:
# Docker: subprocess normal
try:
completed = subprocess.run(
command_tokens,
capture_output=True,
text=True,
timeout=30,
check=False,
)
except FileNotFoundError:
stderr = "El runtime no tiene disponible el binario 'docker'"
supabase.table("incidents").update({
"status": "failed",
"action_result": "",
"action_error": stderr,
"executed_at": executed_at,
}).eq("id", body.incident_id).execute()

try:
completed = subprocess.run(
command_tokens,
capture_output=True,
text=True,
timeout=30,
check=False,
)
except FileNotFoundError:
stderr = "El runtime no tiene disponible el binario 'docker'"
supabase.table("incidents").update({
"status": "failed",
"action_result": "",
"action_error": stderr,
"executed_at": executed_at,
}).eq("id", body.incident_id).execute()

return ExecuteActionResponse(
incident_id=body.incident_id,
status="failed",
exit_code=127,
stdout="",
stderr=stderr,
friendly_message="La ejecución automática falló. Se recomienda revisión manual.",
)
except subprocess.TimeoutExpired as exc:
stderr = (exc.stderr or "").strip() or "Timeout al ejecutar el comando"
stdout = (exc.stdout or "").strip()
supabase.table("incidents").update({
"status": "failed",
"action_result": stdout,
"action_error": stderr,
"executed_at": executed_at,
}).eq("id", body.incident_id).execute()
return ExecuteActionResponse(
incident_id=body.incident_id,
status="failed",
exit_code=127,
stdout="",
stderr=stderr,
friendly_message="La ejecución automática falló. Se recomienda revisión manual.",
)
except subprocess.TimeoutExpired as exc:
stderr = (exc.stderr or "").strip() or "Timeout al ejecutar el comando"
stdout = (exc.stdout or "").strip()
supabase.table("incidents").update({
"status": "failed",
"action_result": stdout,
"action_error": stderr,
"executed_at": executed_at,
}).eq("id", body.incident_id).execute()

return ExecuteActionResponse(
incident_id=body.incident_id,
status="failed",
exit_code=124,
stdout=stdout,
stderr=stderr,
friendly_message="La ejecución automática falló. Se recomienda revisión manual.",
)
return ExecuteActionResponse(
incident_id=body.incident_id,
status="failed",
exit_code=124,
stdout=stdout,
stderr=stderr,
friendly_message="La ejecución automática falló. Se recomienda revisión manual.",
)

stdout = (completed.stdout or "").strip()
stderr = (completed.stderr or "").strip()
exit_code = completed.returncode
stdout = (completed.stdout or "").strip()
stderr = (completed.stderr or "").strip()

if completed.returncode == 0:
if exit_code == 0:
supabase.table("incidents").update({
"status": "verifying",
"action_result": stdout,
Expand All @@ -344,7 +387,7 @@ async def execute_action(
return ExecuteActionResponse(
incident_id=body.incident_id,
status="verifying",
exit_code=completed.returncode,
exit_code=exit_code,
stdout=stdout,
stderr=stderr,
)
Expand All @@ -360,7 +403,7 @@ async def execute_action(
return ExecuteActionResponse(
incident_id=body.incident_id,
status="failed",
exit_code=completed.returncode,
exit_code=exit_code,
stdout=stdout,
stderr=stderr,
friendly_message="La ejecución automática falló. Se recomienda revisión manual.",
Expand Down
4 changes: 2 additions & 2 deletions Backend/services/agents/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
from services.agents.base import DomainAgent, IncidentContext

# Importa los dominios concretos para que se auto-registren.
from services.agents.docker import agent as _docker_agent # noqa: F401
from services.agents.podman import agent as _podman_agent # noqa: F401
from services.agents.docker import agent as _docker_agent # noqa: F401
from services.agents.podman import agent as _podman_agent # noqa: F401
from services.agents.postgres import agent as _postgres_agent # noqa: F401
from services.agents.registry import get_agent, list_agents, register_agent

Expand Down
3 changes: 2 additions & 1 deletion Backend/services/agents/memory/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

Cada dominio tiene sus propias colecciones:
runbooks-docker, incidents-docker
runbooks-kubernetes, incidents-kubernetes (pendiente — ver docs/kubernetes_integration_handoff.md)
runbooks-kubernetes, incidents-kubernetes
(pendiente — ver docs/kubernetes_integration_handoff.md)
runbooks-postgres, incidents-postgres (activo desde Sprint 2)
"""
31 changes: 22 additions & 9 deletions Backend/services/agents/postgres/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,16 +82,23 @@ def pg_stat_activity(datname: str = "") -> str:
return _unavailable("pg_stat_activity", err or "")

try:
where = "WHERE datname = %(datname)s" if datname else "WHERE datname NOT IN ('template0', 'template1', '')"
excluded = "('template0', 'template1', '')"
where = (
"WHERE datname = %(datname)s"
if datname
else f"WHERE datname NOT IN {excluded}"
)
rows = _query(dsn, f"""
SELECT
datname,
state,
wait_event_type,
wait_event,
COUNT(*) AS conexiones,
MAX(EXTRACT(EPOCH FROM (now() - query_start))) AS max_duracion_s,
MAX(EXTRACT(EPOCH FROM (now() - state_change))) AS max_estado_s
COUNT(*) AS conexiones,
MAX(EXTRACT(EPOCH FROM (now() - query_start)))
AS max_duracion_s,
MAX(EXTRACT(EPOCH FROM (now() - state_change)))
AS max_estado_s
FROM pg_stat_activity
{where}
GROUP BY datname, state, wait_event_type, wait_event
Expand All @@ -103,7 +110,8 @@ def pg_stat_activity(datname: str = "") -> str:
SELECT
datname,
COUNT(*) AS total_conexiones,
(SELECT setting::int FROM pg_settings WHERE name = 'max_connections') AS max_connections
(SELECT setting::int FROM pg_settings
WHERE name = 'max_connections') AS max_connections
FROM pg_stat_activity
{where}
GROUP BY datname
Expand All @@ -125,13 +133,18 @@ def pg_stat_database(datname: str = "") -> str:
return _unavailable("pg_stat_database", err or "")

try:
where = "WHERE datname = %(datname)s" if datname else "WHERE datname NOT IN ('template0', 'template1', '')"
excluded = "('template0', 'template1', '')"
where = (
"WHERE datname = %(datname)s"
if datname
else f"WHERE datname NOT IN {excluded}"
)
rows = _query(dsn, f"""
SELECT
datname,
numbackends AS conexiones_activas,
xact_commit AS commits,
xact_rollback AS rollbacks,
numbackends AS conexiones_activas,
xact_commit AS commits,
xact_rollback AS rollbacks,
deadlocks,
blks_read AS lecturas_disco,
blks_hit AS lecturas_cache,
Expand Down
28 changes: 19 additions & 9 deletions Backend/services/verification.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,11 @@ def verify_resolution(

section = _render_section(check, runtime)
new_status = "resolved" if check["healthy"] else "failed"
resolved_at = datetime.now(tz=timezone.utc).isoformat() if new_status == "resolved" else None

resolved_at = (
datetime.now(tz=timezone.utc).isoformat()
if new_status == "resolved"
else None
)
update = {
"status": new_status,
"agent_reasoning": (current_reasoning or "") + "\n\n---\n\n" + section,
Expand Down Expand Up @@ -144,8 +147,8 @@ def _inspect_postgres(datname: str) -> dict:
Retorna un dict compatible con _render_section para runtime='postgres'.
"""
try:
import psycopg2 # type: ignore
import psycopg2.extras # type: ignore
import psycopg2 # type: ignore
import psycopg2.extras # type: ignore
except ImportError:
return {
"healthy": False,
Expand Down Expand Up @@ -182,9 +185,12 @@ def _inspect_postgres(datname: str) -> dict:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute("""
SELECT
(SELECT count(*) FROM pg_stat_activity WHERE datname = %(dn)s)::int AS connections,
(SELECT setting::int FROM pg_settings WHERE name = 'max_connections') AS max_connections,
round(pg_database_size(%(dn)s) / 1024.0 / 1024.0, 1) AS db_size_mb
(SELECT count(*) FROM pg_stat_activity
WHERE datname = %(dn)s)::int AS connections,
(SELECT setting::int FROM pg_settings
WHERE name = 'max_connections') AS max_connections,
round(pg_database_size(%(dn)s) / 1024.0 / 1024.0, 1)
AS db_size_mb
""", {"dn": datname})
row = dict(cur.fetchone())

Expand Down Expand Up @@ -223,7 +229,9 @@ def _render_container_section(r: dict) -> str:
summary = "El contenedor volvió a estado `running`. El reinicio fue exitoso."
else:
heading = "Verificación de resolución — Servicio no recuperado"
summary = f"El contenedor está en estado `{r['status']}` después del reinicio. Requiere atención manual."
state = r['status']
summary = f"El contenedor está en estado `{state}` después del "
summary += "reinicio. Requiere atención manual."

rows = [
f"| Estado | `{r['status']}` |",
Expand Down Expand Up @@ -252,7 +260,9 @@ def _render_postgres_section(r: dict) -> str:
summary = "La base de datos responde correctamente y acepta conexiones."
else:
heading = "Verificación de resolución — PostgreSQL no disponible"
summary = f"No se pudo conectar a la base de datos (`{r['status']}`). Requiere atención manual."
status = r['status']
summary = "No se pudo conectar a la base de datos "
summary += f"(`{status}`). Requiere atención manual."

rows = [f"| Estado | `{r['status']}` |"]
if r.get("connections") is not None:
Expand Down
Empty file added Backend/tests/__init__.py
Empty file.
46 changes: 46 additions & 0 deletions Backend/tests/test_smoke.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""
Smoke tests: verifican que la app FastAPI arranca y responde.
"""
import os
import sys
from pathlib import Path

import pytest
from fastapi.testclient import TestClient

# Asegurar que Backend/ está en sys.path cuando pytest corre desde ahí
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))

os.environ.setdefault("SUPABASE_URL", "http://localhost:54321")
os.environ.setdefault("SUPABASE_SERVICE_KEY", "test-service-key")
os.environ.setdefault("SUPABASE_JWT_SECRET", "test-jwt-secret-min-32-characters-long")
os.environ.setdefault("OPENAI_API_KEY", "sk-test")

from auth import get_current_user # noqa: E402
from main import app # noqa: E402


@pytest.fixture
def client():
app.dependency_overrides[get_current_user] = lambda: {"sub": "test-user"}
with TestClient(app) as c:
yield c
app.dependency_overrides.clear()


def test_app_starts():
"""La app arranca sin errores de importación."""
assert app is not None


def test_health_incidents_requires_auth(client):
"""GET /api/incidents sin token devuelve 401 o 200 con override."""
app.dependency_overrides.clear()
response = client.get("/api/incidents")
assert response.status_code in (200, 401, 403, 422)


def test_alerts_endpoint_exists(client):
"""POST /api/alerts con body vacío devuelve 422 (validación), no 404."""
response = client.post("/api/alerts", json={})
assert response.status_code != 404
Loading
Loading