Jose Salazar Claude Opus 5 commited on
Commit
b90d0d2
·
1 Parent(s): 5118fc3

Esquema versionado y BD de usuarios en volumen persistente si lo hay

Browse files

ARCHITECTURE_REVIEW §3.4.

MIGRACIONES. El esquema era un único `CREATE TABLE IF NOT EXISTS`: creaba las
tablas en una BD vacía y no hacía NADA sobre una existente, así que añadir una
columna era una operación manual sobre un fichero al que, en Spaces, nadie puede
llegar. Ahora `PRAGMA user_version` con pasos numerados; sólo se aplican los
pendientes. Los pasos son reejecutables sobre lo ya existente porque las BD
creadas antes de esto están en la versión 0 con las tablas del paso 1 ya dentro
—ese es el caso de la instancia desplegada, y hay prueba de que se pone al día
sin perder cuentas—. Paso 2: índice para el throttle de login, que filtraba por
email+ip+momento con scan completo en cada intento.

PERSISTENCIA. `db_path` apuntaba siempre a `instance/`, que en Spaces no
sobrevive al reinicio: cada rebuild se llevaba cuentas, hashes e historial. Ahora
se elige /data si hay un volumen persistente montado ahí, y si no se cae a
`instance/` AVISANDO al arrancar. Se elige solo porque el fallo del defecto
anterior era silencioso: la app arrancaba igual y sólo se notaba cuando los
usuarios ya no podían entrar. Sigue forzable con MORPHOS_DB_PATH.

MYSQL FANTASMA. `mysql_dsn`/`mysql_user`/`mysql_password` decían «si se define, se
usa en vez de SQLite» y NADA en el código los leía: quien los configurara seguiría
sobre SQLite sin enterarse. Se eliminan. Una opción de configuración que miente es
peor que no tenerla.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

backend/.env.example CHANGED
@@ -21,8 +21,12 @@ MORPHOS_COOKIE_SECURE=false # true en prod (HTTPS)
21
  # MORPHOS_REGISTRO_ALLOWLIST=vet1@clinica.com,vet2@clinica.com
22
  MORPHOS_REGISTRO_ABIERTO=false # true SÓLO en desarrollo local
23
 
24
- # --- Base de datos de usuarios (SQLite fuera del webroot por defecto) ---
25
- # MORPHOS_DB_PATH=/ruta/fuera/webroot/morphos.db
 
 
 
 
26
 
27
  # --- Ruta IA por defecto ---
28
  MORPHOS_IA_BACKEND_DEFECTO=medgemma # medgemma | claude
 
21
  # MORPHOS_REGISTRO_ALLOWLIST=vet1@clinica.com,vet2@clinica.com
22
  MORPHOS_REGISTRO_ABIERTO=false # true SÓLO en desarrollo local
23
 
24
+ # --- Base de datos de usuarios (SQLite fuera del webroot) ---
25
+ # Por defecto se usa /data/morphos.db si existe un volumen PERSISTENTE ahí (es donde HF Spaces
26
+ # monta el almacenamiento contratado); si no, instance/morphos.db, que es EFÍMERO: cada
27
+ # reinicio borra cuentas, contraseñas e historial de intentos y todo el mundo se vuelve a
28
+ # registrar. Se avisa al arrancar. NO hay soporte de MySQL pese a lo que decía la config vieja.
29
+ # MORPHOS_DB_PATH=/data/morphos.db
30
 
31
  # --- Ruta IA por defecto ---
32
  MORPHOS_IA_BACKEND_DEFECTO=medgemma # medgemma | claude
backend/app/config.py CHANGED
@@ -21,6 +21,23 @@ log = logging.getLogger(__name__)
21
  # Raíz del repo (…/morphos). La BD y el índice RAG viven FUERA del directorio servido.
22
  RAIZ_REPO = Path(__file__).resolve().parents[2]
23
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
 
25
  # El `.env` del desarrollador NO debe filtrarse a las pruebas: son las mismas que corren en CI,
26
  # donde ese fichero no existe, así que cualquier valor que se cuele hace que pasen o fallen según
@@ -66,10 +83,16 @@ class Configuracion(BaseSettings):
66
  registro_allowlist: Annotated[list[str], NoDecode] = Field(default_factory=list)
67
 
68
  # --- Base de datos (usuarios). Ruta fuera del webroot. ---
69
- db_path: Path = Field(default=RAIZ_REPO / "instance" / "morphos.db")
70
- mysql_dsn: str = Field(default="") # si se define, se usa en vez de SQLite
71
- mysql_user: str = Field(default="")
72
- mysql_password: str = Field(default="")
 
 
 
 
 
 
73
 
74
  # --- Ruta IA por defecto y proveedores ---
75
  ia_backend_defecto: str = Field(default="medgemma") # medgemma | claude
 
21
  # Raíz del repo (…/morphos). La BD y el índice RAG viven FUERA del directorio servido.
22
  RAIZ_REPO = Path(__file__).resolve().parents[2]
23
 
24
+ # Punto de montaje del almacenamiento persistente en HF Spaces.
25
+ VOLUMEN_PERSISTENTE = Path("/data")
26
+
27
+
28
+ def _ruta_db_por_defecto() -> Path:
29
+ """Volumen persistente si lo hay; si no, `instance/` (efímero) con aviso al arrancar.
30
+
31
+ En HF Spaces `instance/` se pierde en cada reinicio y con él TODAS las cuentas. Si el Space
32
+ tiene almacenamiento persistente contratado, se monta en /data y ahí sí sobreviven. Se elige
33
+ solo en vez de exigir configuración porque el fallo del defecto anterior era silencioso: la
34
+ app arrancaba igual y el problema sólo se veía cuando los usuarios ya no podían entrar.
35
+ Siempre se puede forzar con MORPHOS_DB_PATH.
36
+ """
37
+ if VOLUMEN_PERSISTENTE.is_dir() and os.access(VOLUMEN_PERSISTENTE, os.W_OK):
38
+ return VOLUMEN_PERSISTENTE / "morphos.db"
39
+ return RAIZ_REPO / "instance" / "morphos.db"
40
+
41
 
42
  # El `.env` del desarrollador NO debe filtrarse a las pruebas: son las mismas que corren en CI,
43
  # donde ese fichero no existe, así que cualquier valor que se cuele hace que pasen o fallen según
 
83
  registro_allowlist: Annotated[list[str], NoDecode] = Field(default_factory=list)
84
 
85
  # --- Base de datos (usuarios). Ruta fuera del webroot. ---
86
+ # Se prefiere el volumen persistente si existe (ver `_ruta_db_por_defecto`). `instance/` NO
87
+ # sobrevive a un reinicio en Spaces: allí cada rebuild se llevaba por delante las cuentas,
88
+ # los hashes y el historial de throttling, y los usuarios tenían que volver a registrarse.
89
+ db_path: Path = Field(default_factory=lambda: _ruta_db_por_defecto())
90
+
91
+ # NO hay soporte de MySQL. Existían `mysql_dsn`/`mysql_user`/`mysql_password` con el
92
+ # comentario «si se define, se usa en vez de SQLite», pero NADA en el código los leía: quien
93
+ # los configurara seguiría sobre SQLite sin enterarse. Se eliminan en vez de dejarlos: una
94
+ # opción de configuración que miente es peor que no tenerla. Para sacar los usuarios de
95
+ # SQLite, el camino es un volumen persistente (MORPHOS_DB_PATH) o portar `db.py`.
96
 
97
  # --- Ruta IA por defecto y proveedores ---
98
  ia_backend_defecto: str = Field(default="medgemma") # medgemma | claude
backend/app/db.py CHANGED
@@ -10,14 +10,29 @@ from __future__ import annotations
10
 
11
  import hashlib
12
  import hmac
 
13
  import secrets
14
  import sqlite3
15
  from collections.abc import Iterator
16
  from contextlib import contextmanager
17
 
18
- from .config import obtener_config
19
-
20
- _ESQUEMA = """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  CREATE TABLE IF NOT EXISTS usuarios (
22
  id INTEGER PRIMARY KEY AUTOINCREMENT,
23
  nombre TEXT NOT NULL,
@@ -39,14 +54,40 @@ CREATE TABLE IF NOT EXISTS resultados_lab (
39
  recibido_en DATETIME DEFAULT CURRENT_TIMESTAMP,
40
  payload_json TEXT NOT NULL
41
  );
42
- """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
 
44
 
45
  def inicializar_db() -> None:
46
  cfg = obtener_config()
47
  cfg.db_path.parent.mkdir(parents=True, exist_ok=True)
48
  with _conexion() as con:
49
- con.executescript(_ESQUEMA)
 
 
 
 
 
 
 
 
 
50
 
51
 
52
  @contextmanager
 
10
 
11
  import hashlib
12
  import hmac
13
+ import logging
14
  import secrets
15
  import sqlite3
16
  from collections.abc import Iterator
17
  from contextlib import contextmanager
18
 
19
+ from .config import VOLUMEN_PERSISTENTE, obtener_config
20
+
21
+ log = logging.getLogger("morphos.db")
22
+
23
+ # Migraciones versionadas con `PRAGMA user_version`. Antes esto era un único script de
24
+ # `CREATE TABLE IF NOT EXISTS`: creaba el esquema en una BD vacía y no hacía NADA sobre una
25
+ # existente, así que añadir una columna era una operación manual sobre un fichero al que, en
26
+ # Spaces, nadie puede llegar. Cada entrada de la lista es un paso; el índice+1 es la versión
27
+ # resultante, y sólo se aplican los pasos por encima de la versión actual.
28
+ #
29
+ # Reglas: nunca se edita un paso ya publicado (una BD que lo aplicó no volvería a ejecutarlo),
30
+ # los pasos se añaden al final, y cada uno debe poder correr sobre una BD que ya lo tuviera
31
+ # —de ahí los `IF NOT EXISTS`—, porque las BD creadas antes de este mecanismo están en la
32
+ # versión 0 con las tablas del paso 1 ya presentes.
33
+ _MIGRACIONES: list[str] = [
34
+ # 1 — esquema inicial (el que ya existía).
35
+ """
36
  CREATE TABLE IF NOT EXISTS usuarios (
37
  id INTEGER PRIMARY KEY AUTOINCREMENT,
38
  nombre TEXT NOT NULL,
 
54
  recibido_en DATETIME DEFAULT CURRENT_TIMESTAMP,
55
  payload_json TEXT NOT NULL
56
  );
57
+ """,
58
+ # 2 — índice para el throttle de login: `intentos_recientes` filtra por email+ip+momento en
59
+ # cada intento y hacía scan completo de la tabla.
60
+ """
61
+ CREATE INDEX IF NOT EXISTS idx_intentos_email_ip_momento
62
+ ON intentos_login (email, ip, momento);
63
+ """,
64
+ ]
65
+
66
+
67
+ def _migrar(con: sqlite3.Connection) -> int:
68
+ """Aplica los pasos pendientes y devuelve la versión resultante."""
69
+ version = con.execute("PRAGMA user_version").fetchone()[0]
70
+ for indice in range(version, len(_MIGRACIONES)):
71
+ con.executescript(_MIGRACIONES[indice])
72
+ # `PRAGMA` no admite parámetros; el valor es un índice entero nuestro, no entrada.
73
+ con.execute(f"PRAGMA user_version = {indice + 1}")
74
+ return len(_MIGRACIONES)
75
 
76
 
77
  def inicializar_db() -> None:
78
  cfg = obtener_config()
79
  cfg.db_path.parent.mkdir(parents=True, exist_ok=True)
80
  with _conexion() as con:
81
+ version = _migrar(con)
82
+ persistente = cfg.db_path.is_relative_to(VOLUMEN_PERSISTENTE)
83
+ log.info("BD en %s (esquema v%d, %s).", cfg.db_path, version,
84
+ "persistente" if persistente else "EFÍMERA: las cuentas no sobreviven al reinicio")
85
+ if not persistente:
86
+ log.warning(
87
+ "La base de usuarios está en almacenamiento EFÍMERO (%s): cada reinicio borra "
88
+ "cuentas, contraseñas e historial de intentos. Monta un volumen persistente o "
89
+ "apunta MORPHOS_DB_PATH a uno.", cfg.db_path,
90
+ )
91
 
92
 
93
  @contextmanager
backend/tests/test_migraciones_db.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Versionado del esquema de la BD de usuarios.
2
+
3
+ Antes era un único `CREATE TABLE IF NOT EXISTS`: creaba el esquema en una BD vacía y no hacía
4
+ NADA sobre una existente. Añadir una columna era una operación manual sobre un fichero al que,
5
+ en HF Spaces, nadie puede llegar.
6
+
7
+ Lo que hay que sostener: los pasos se aplican una sola vez, se aplican en orden, y una BD creada
8
+ ANTES de este mecanismo (versión 0 con las tablas del paso 1 ya dentro) se pone al día sin
9
+ romperse. Ese último es el caso real de la instancia desplegada.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import sqlite3
15
+
16
+ import pytest
17
+
18
+ from app import db
19
+
20
+
21
+ @pytest.fixture
22
+ def bd(tmp_path, monkeypatch):
23
+ """BD temporal aislada de la del resto de la suite."""
24
+ ruta = tmp_path / "prueba.db"
25
+ monkeypatch.setattr(db.obtener_config(), "db_path", ruta)
26
+ return ruta
27
+
28
+
29
+ def _version(ruta) -> int:
30
+ con = sqlite3.connect(ruta)
31
+ try:
32
+ return con.execute("PRAGMA user_version").fetchone()[0]
33
+ finally:
34
+ con.close()
35
+
36
+
37
+ def _tablas(ruta) -> set[str]:
38
+ con = sqlite3.connect(ruta)
39
+ try:
40
+ filas = con.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()
41
+ return {f[0] for f in filas}
42
+ finally:
43
+ con.close()
44
+
45
+
46
+ def test_bd_nueva_queda_en_la_ultima_version(bd):
47
+ db.inicializar_db()
48
+ assert _version(bd) == len(db._MIGRACIONES)
49
+ assert {"usuarios", "intentos_login", "resultados_lab"} <= _tablas(bd)
50
+
51
+
52
+ def test_reejecutar_es_idempotente(bd):
53
+ """Arrancar dos veces no debe reaplicar nada ni fallar."""
54
+ db.inicializar_db()
55
+ db.inicializar_db()
56
+ assert _version(bd) == len(db._MIGRACIONES)
57
+
58
+
59
+ def test_bd_anterior_al_versionado_se_pone_al_dia(bd):
60
+ """El caso real: tablas del paso 1 ya presentes y `user_version` en 0.
61
+
62
+ Si los pasos no fueran reejecutables sobre lo ya existente, esto reventaría con
63
+ «table usuarios already exists» y la app no arrancaría contra la BD desplegada.
64
+ """
65
+ con = sqlite3.connect(bd)
66
+ con.executescript(db._MIGRACIONES[0]) # esquema viejo, sin tocar user_version
67
+ con.commit()
68
+ con.close()
69
+ assert _version(bd) == 0
70
+
71
+ db.inicializar_db()
72
+
73
+ assert _version(bd) == len(db._MIGRACIONES)
74
+ assert {"usuarios", "intentos_login", "resultados_lab"} <= _tablas(bd)
75
+
76
+
77
+ def test_los_datos_sobreviven_a_la_migracion(bd):
78
+ """Migrar no puede perder cuentas: es exactamente lo que se está intentando dejar de hacer."""
79
+ con = sqlite3.connect(bd)
80
+ con.executescript(db._MIGRACIONES[0])
81
+ con.execute(
82
+ "INSERT INTO usuarios (nombre, apellido, email, password) VALUES (?,?,?,?)",
83
+ ("Ana", "Vet", "ana@example.com", "scrypt$x$y"),
84
+ )
85
+ con.commit()
86
+ con.close()
87
+
88
+ db.inicializar_db()
89
+
90
+ assert db.buscar_usuario("ana@example.com") is not None
91
+
92
+
93
+ def test_se_aplica_un_paso_nuevo_sobre_una_bd_ya_migrada(bd, monkeypatch):
94
+ """Comprueba el mecanismo, no un paso concreto: sin esto, sólo se probaría el estado final."""
95
+ db.inicializar_db()
96
+ ya = len(db._MIGRACIONES)
97
+
98
+ monkeypatch.setattr(
99
+ db, "_MIGRACIONES", [*db._MIGRACIONES, "CREATE TABLE IF NOT EXISTS futura (x INTEGER);"]
100
+ )
101
+ db.inicializar_db()
102
+
103
+ assert _version(bd) == ya + 1
104
+ assert "futura" in _tablas(bd)
105
+
106
+
107
+ def test_el_indice_del_throttle_existe(bd):
108
+ """Paso 2: `intentos_recientes` filtra por email+ip+momento en cada intento de login."""
109
+ db.inicializar_db()
110
+ con = sqlite3.connect(bd)
111
+ try:
112
+ indices = {f[0] for f in con.execute(
113
+ "SELECT name FROM sqlite_master WHERE type='index'"
114
+ ).fetchall()}
115
+ finally:
116
+ con.close()
117
+ assert "idx_intentos_email_ip_momento" in indices