Volver a la wiki

Decisión: Invariante "Gestión de red válida → Monitoreo activo" en DOS reconciliadores

Decisión

Lugar: MonitoringTarget.get_or_create_for_device + observatory_view

Principio: Un dispositivo cuya configuración de red es válida nunca puede tener monitoreo apagado, sin excepción.

Implementación: Re-encender MonitoringTarget.enabled=True en DOS puntos de reconciliación independientes para garantizar cobertura total.


Contexto

En el incidente s234, la malla de relaciones entre Device → management_config → MonitoringTarget.enabled se rompió:

  1. Device de prueba se guardó con IP de un AP real
  2. Validación falló → enabled=False por arrastre
  3. Device se eliminó
  4. Target quedó en “estado zombi”: apagado, pero sin triggers que lo re-encendieran

El bug no fue “datos corruptos en BD”, sino falta de enforcement de una invariante de negocio.


Invariante

∀ device ∈ Device, ∀ target ∈ MonitoringTarget:
  device.management_config.enabled ∧ device.management_config.valid
  ⟹ target.enabled = True

En lenguaje natural:

Si un dispositivo tiene una configuración de gestión de red válida y activa, su MonitoringTarget debe estar activo.


Implementación: DOS reconciliadores

1. MonitoringTarget.get_or_create_for_device (modela el flujo “hotpath”)

Se llama cuando:

# monitoreo/models.py, línea ~217
if existing:
    update_fields = []
    if existing.ip_address != ip:
        existing.ip_address = ip
        update_fields.append("ip_address")
    # Invariante: gestión válida → target activo
    if not existing.enabled:
        existing.enabled = True
        update_fields.append("enabled")
    if update_fields:
        existing.save(update_fields=update_fields)
    return existing, False

Cubierto: Búsqueda por FK (device_id) y búsqueda por IP (Ficha Central).

2. observatory_view (batch “cold-path”)

Se ejecuta cuando el usuario abre Observatory (/monitoring/), reconcilia estado en lote:

# monitoring/views.py, línea ~196
if existing:
    if existing.ip_address != ip or not existing.enabled:
        existing.ip_address = ip
        existing.enabled = True  # ← Enforcing invariante en el batch
        obs_targets_to_update.append(existing)

# ...

if obs_targets_to_update:
    MonitoringTarget.objects.bulk_update(
        obs_targets_to_update, 
        ["ip_address", "device", "name", "enabled"]  # ← enabled en la lista
    )

Cubierto: Batch de devices → targets en Observatory, garantiza consistency antes de renderizar.


Por qué DOS lugares (y no uno)

Scenarioget_or_create_for_deviceobservatory_viewResultado
Device se actualiza vía API✅—✓ Re-enable inmediato
Usuario abre Observatory con target apagado—✅✓ Re-enable en batch
Device se borra pero target queda (zombi)✅ (si se sincroniza de nuevo)✅✓ Double-check
Transición de IP por rebalanceo✅✅✓ Redundancia

La redundancia es intencional: no confiar en que un solo punto de entrada ejecute. En un sistema SaaS con múltiples orígenes de verdad (API, Agente, UI, batch), es mejor sobre-enforcement que bajo-enforcement.


Tests que validan la invariante

test_get_or_create_reenables_zombie

device, target = self._zombie(organization, rack, ip="192.168.42.7")
# target.enabled = False (estado zombi)

found, created = MonitoringTarget.get_or_create_for_device(device, organization)
assert created is False
assert found.id == target.id

target.refresh_from_db()
assert target.enabled is True  # ← Invariante enforced

test_observatory_view_reenables_zombie

_, target = self._zombie(organization, rack, ip="192.168.42.8")
# target.enabled = False

resp = authenticated_client.get("/monitoring/")
assert resp.status_code == 200

target.refresh_from_db()
assert target.enabled is True  # ← Invariante enforced por el batch

Impacto arquitectónico

Posibles transiciones de estado

device.management_config.valid=False
  └─> MonitoringTarget creado con enabled=False (OK, intención)
      └─> device.management_config.valid=True (cambio de IP, reconfig)
          └─> get_or_create_for_device: enabled → True (invariante)
          └─> observatory_view: enabled → True (redundancia)

device delete() mientras target.enabled=False
  └─> target queda "huérfano" (device_id=NULL)
      └─> Si alguna lógica intenta re-ligar por IP → invariante enforced
      └─> Si no, el target puede quedar zombi
          └─> pero observatory_view en el siguiente reload: enabled → True

Cambio mínimo en schema

Solo añade lógica a update_fields — no toca migraciones, no cambia el modelo.


Alternativas consideradas y rechazadas

AlternativaProContraDecisión
Señal pre_save en DeviceCentralizado en un lugarLógico: modelo de Device no debería saber de MonitoringTarget✗
Trigger en BD (PostgreSQL)Garantizado a nivel SQLOpaco, difícil de debuggear, no portable✗
Un solo punto (get_or_create)SimplerNo cubre Observatory batch, riesgo de regresión✗
DOS reconciliadores independientesRedundancia, cobertura total, debuggeableLigeramente más código✅

Monitoring de la invariante

Hoy: Tests de regresión (TestZombieTargetReenable).

Futuro:


Véase también

Subir