diff --git a/knowledge b/knowledge index 3ea2f52ec..665a13d88 160000 --- a/knowledge +++ b/knowledge @@ -1 +1 @@ -Subproject commit 3ea2f52ec2f3484e5634dc98aeb5050230117f89 +Subproject commit 665a13d886cbac5ba96c50cb217cffc55a7fcb81 diff --git a/sapl/integracao_hub/management/commands/materializar_pdfs_para_assinatura.py b/sapl/integracao_hub/management/commands/materializar_pdfs_para_assinatura.py index 73f649459..0eb5eb168 100644 --- a/sapl/integracao_hub/management/commands/materializar_pdfs_para_assinatura.py +++ b/sapl/integracao_hub/management/commands/materializar_pdfs_para_assinatura.py @@ -10,7 +10,8 @@ from django.core.management.base import BaseCommand, CommandError from django.db import IntegrityError, transaction from django.utils import timezone -from sapl.integracao_hub.models import (DocumentoParaAssinatura, +from sapl.integracao_hub.models import (BatimentoLaco, + DocumentoParaAssinatura, MateriaComFalhaMaterializacao, MateriaParaMaterializar, PassadaMaterializacao) @@ -177,6 +178,31 @@ class Command(BaseCommand): if intervalo <= 0: self._passada_registrada(somente_novos, disparada_por) return + + # Instância única do LAÇO (ADR 0015): batimento fresco de outro + # processo = já existe laço vivo, este sai. O lock de passada impedia + # conversão dupla; isto impede dois laços residentes disputando — o + # cenário que a autossupervisão cria quando dois workers do gunicorn + # decidem ressuscitar o laço no mesmo segundo. + batimento = BatimentoLaco.objects.first() + if batimento and batimento.fresco and batimento.pid != os.getpid(): + self.stdout.write( + 'materializar_pdfs: ja existe laco vivo (pid %s, batimento ' + '%s) — este processo sai' % (batimento.pid, batimento.visto_em)) + return + + self._batendo = True + self._tick = tick + self._iniciado_em = timezone.now() + # A versao do codigo em disco no momento da subida. Quando um deploy + # troca o arquivo, o laco RESIDENTE continuaria rodando o codigo velho + # para sempre — entao ele se encerra e deixa a autossupervisao (ou o + # supervisor) ressuscita-lo ja atualizado. + try: + self._versao_codigo = os.path.getmtime(__file__) + except OSError: + self._versao_codigo = None + self.stdout.write( 'materializar_pdfs: laco — varredura completa a cada %ss, fila de ' 'prioridade a cada %ss (Ctrl-C para sair)' % (intervalo, tick)) @@ -185,6 +211,15 @@ class Command(BaseCommand): # so um tick de folga. proxima_varredura = 0.0 while True: + self._bater() + if self._codigo_mudou_no_disco(): + self.stdout.write( + 'materializar_pdfs: codigo novo no disco — laco sai para ' + 'renascer atualizado (autossupervisao/supervisor sobem ' + 'outro)') + logger.info( + 'materializar_pdfs: laco encerrado por deploy detectado') + return try: if time.monotonic() >= proxima_varredura: try: @@ -202,6 +237,50 @@ class Command(BaseCommand): logger.exception('materializar_pdfs: passada falhou: %s', exc) time.sleep(tick) + # Estado do modo laço (ADR 0015). Fora do laço (passada manual, botão da + # tela, --fila) nada disso é tocado: `_batendo` False faz `_bater` ser um + # no-op e o batimento continua contando só a vida do laço residente. + _batendo = False + _tick = 15 + _iniciado_em = None + _versao_codigo = None + _ultimo_batimento_monotonic = 0.0 + + def _bater(self): + """Grava o batimento (singleton pk=1), no máximo a cada 10s. + + Chamado a cada iteração do laço E a cada matéria da varredura: uma + varredura de uma hora sem batimento pareceria laço morto e faria a + autossupervisão subir um segundo processo. + """ + if not self._batendo: + return + agora = time.monotonic() + if (self._ultimo_batimento_monotonic + and agora - self._ultimo_batimento_monotonic < 10): + return + self._ultimo_batimento_monotonic = agora + try: + BatimentoLaco.objects.update_or_create(pk=1, defaults={ + 'visto_em': timezone.now(), + 'iniciado_em': self._iniciado_em, + 'pid': os.getpid(), + 'tick_segundos': self._tick}) + except Exception as exc: # noqa — batimento é sinal vital, não trabalho: + # falhar em gravá-lo não pode derrubar a materialização em si. + logger.warning('materializar_pdfs: falha ao gravar batimento: %s', + exc) + + def _codigo_mudou_no_disco(self): + """Deploy trocou o arquivo do comando? Então este laço está defasado.""" + if self._versao_codigo is None: + return False + try: + return os.path.getmtime(__file__) != self._versao_codigo + except OSError: + # Arquivo sumiu do caminho = deploy em curso: melhor renascer. + return True + def _fechar_orfas(self): """Libera o lock de passada cujo processo morreu sem fechar a linha. @@ -323,6 +402,7 @@ class Command(BaseCommand): gerados = retificados = pulados = falhas = adiados = 0 motivos = {} for materia, marca in pares: + self._bater() # varredura longa não pode parecer laço morto try: resultado, motivo = self._materializar(materia, somente_novos) except Exception as exc: # noqa — uma matéria não trava as demais (§5.1) diff --git a/sapl/integracao_hub/middleware.py b/sapl/integracao_hub/middleware.py new file mode 100644 index 000000000..801a27e5c --- /dev/null +++ b/sapl/integracao_hub/middleware.py @@ -0,0 +1,106 @@ +"""Autossupervisão do laço de materialização (ADR 0015). + +O problema que este módulo mata: o SAPL de produção roda fora de docker, e o +laço de materialização dependia de alguém LEMBRAR de subi-lo (nohup, supervisor, +cron — todos exigem um ato manual que já falhou uma vez, IND 826/2026). O único +processo cuja existência é garantida é o próprio web: o hub bate na API de +integração o tempo inteiro, e cada request é um marca-passo de graça. + +Então: o laço grava um batimento no banco a cada tick (`BatimentoLaco`); este +middleware, no máximo uma vez por minuto por worker, confere o batimento e — +se ele envelheceu ou nunca existiu — ressuscita o laço como processo separado e +desgarrado (mesmo padrão do botão "Rodar agora" do painel: `start_new_session`, +processo sobrevive ao reload do worker). + +Corrida entre N workers ressuscitando ao mesmo tempo: inofensiva por +construção — o laço que sobe e encontra batimento fresco de outro pid SAI na +hora (guarda de instância única no próprio comando), e o lock de passada já +impedia conversão dupla de qualquer forma. + +Desligável por `MATERIALIZACAO_AUTOSSUPERVISAO=False` (ambiente onde o laço é +gerido por supervisor/systemd e não se quer o respawn automático). +""" +import logging +import os +import subprocess +import sys +import time + +from django.conf import settings +from django.core.exceptions import MiddlewareNotUsed + +logger = logging.getLogger(__name__) + +# No máximo uma consulta ao batimento por minuto POR WORKER: o custo por +# request no caminho quente é uma comparação de monotonic. +INTERVALO_VERIFICACAO_SEGUNDOS = 60 + +_ultima_verificacao = 0.0 + + +def laco_precisa_subir(): + """Batimento ausente ou envelhecido = não há laço vivo.""" + from sapl.integracao_hub.models import BatimentoLaco + batimento = BatimentoLaco.objects.first() + return batimento is None or not batimento.fresco + + +def subir_laco(): + """Sobe o laço como processo separado e desgarrado (padrão do painel). + + Intervalo/tick vêm das mesmas variáveis de ambiente do start.sh do + container — uma configuração só, seja quem for que suba o laço. + """ + from sapl.integracao_hub.painel import _raiz_do_projeto + intervalo = os.environ.get('MATERIALIZACAO_INTERVALO_SEGUNDOS', '300') + tick = os.environ.get('MATERIALIZACAO_TICK_SEGUNDOS', '15') + comando = [ + sys.executable, '-m', 'django', + 'materializar_pdfs_para_assinatura', + '--intervalo', intervalo, + '--tick', tick, + ] + ambiente = os.environ.copy() + ambiente.setdefault('DJANGO_SETTINGS_MODULE', 'sapl.settings') + subprocess.Popen( + comando, + cwd=_raiz_do_projeto(), + env=ambiente, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True) + logger.warning( + 'autossupervisao: laco de materializacao sem batimento — novo laco ' + 'disparado (intervalo=%ss, tick=%ss)', intervalo, tick) + + +class AutossupervisaoMaterializacao: + def __init__(self, get_response): + # Em teste, spawnar processo residente por efeito colateral de request + # seria vazamento; quem testa as peças chama as funções deste módulo. + if 'pytest' in sys.modules: + raise MiddlewareNotUsed('autossupervisao desligada sob pytest') + if not getattr(settings, 'MATERIALIZACAO_AUTOSSUPERVISAO', True): + raise MiddlewareNotUsed('MATERIALIZACAO_AUTOSSUPERVISAO=False') + self.get_response = get_response + + def __call__(self, request): + self._garantir_laco() + return self.get_response(request) + + def _garantir_laco(self): + global _ultima_verificacao + agora = time.monotonic() + if _ultima_verificacao and agora - _ultima_verificacao < \ + INTERVALO_VERIFICACAO_SEGUNDOS: + return + _ultima_verificacao = agora + try: + if laco_precisa_subir(): + subir_laco() + except Exception as exc: # noqa — a autossupervisão NUNCA pode custar + # um request: banco sem migração, tabela ausente, falha de spawn — + # tudo vira log e o SAPL segue servindo. + logger.warning('autossupervisao: verificacao falhou (%s) — ' + 'seguira tentando a cada %ss', exc, + INTERVALO_VERIFICACAO_SEGUNDOS) diff --git a/sapl/integracao_hub/migrations/0007_batimento_laco.py b/sapl/integracao_hub/migrations/0007_batimento_laco.py new file mode 100644 index 000000000..8568a8ef7 --- /dev/null +++ b/sapl/integracao_hub/migrations/0007_batimento_laco.py @@ -0,0 +1,25 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('integracao_hub', '0006_fila_prioridade_materializacao'), + ] + + operations = [ + migrations.CreateModel( + name='BatimentoLaco', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('visto_em', models.DateTimeField(verbose_name='Último batimento')), + ('iniciado_em', models.DateTimeField(verbose_name='Laço iniciado em')), + ('pid', models.IntegerField(verbose_name='PID do processo do laço')), + ('tick_segundos', models.IntegerField(default=15, help_text='Gravado pelo próprio laço: é a referência para decidir se o batimento envelheceu.', verbose_name='Tick configurado (s)')), + ], + options={ + 'verbose_name': 'Batimento do Laço de Materialização', + 'verbose_name_plural': 'Batimentos do Laço de Materialização', + }, + ), + ] diff --git a/sapl/integracao_hub/models.py b/sapl/integracao_hub/models.py index 78d19f513..2f9ce1ce1 100644 --- a/sapl/integracao_hub/models.py +++ b/sapl/integracao_hub/models.py @@ -363,3 +363,47 @@ class MateriaParaMaterializar(models.Model): def __str__(self): return 'Matéria %s na fila desde %s' % (self.materia_id, self.marcada_em) + + +class BatimentoLaco(models.Model): + """Batimento do laço de materialização — singleton (pk=1), ADR 0015. + + O laço grava aqui a cada tick (e durante varreduras longas, a cada poucos + segundos). É o que permite a autossupervisão: o middleware do processo web + — o único processo cuja existência é garantida, porque o hub bate na API o + tempo todo — lê este batimento e, se ele envelhecer, ressuscita o laço. + Ninguém precisa lembrar de subir nada: nem supervisor, nem cron, nem Léo. + + Também é o guarda de instância única: um laço que sobe e encontra + batimento fresco de OUTRO processo sai imediatamente — o lock de passada + já impedia conversão dupla; este impede dois laços vivos disputando. + """ + + visto_em = models.DateTimeField( + verbose_name=_('Último batimento')) + + iniciado_em = models.DateTimeField( + verbose_name=_('Laço iniciado em')) + + pid = models.IntegerField( + verbose_name=_('PID do processo do laço')) + + tick_segundos = models.IntegerField( + default=15, + verbose_name=_('Tick configurado (s)'), + help_text=_('Gravado pelo próprio laço: é a referência para decidir ' + 'se o batimento envelheceu.')) + + class Meta: + verbose_name = _('Batimento do Laço de Materialização') + verbose_name_plural = _('Batimentos do Laço de Materialização') + + def __str__(self): + return 'Laço pid %s, batimento %s' % (self.pid, self.visto_em) + + @property + def fresco(self): + """Vivo = bateu há menos de 3 ticks (piso de 60s para folga de carga).""" + from django.utils import timezone + janela = max(3 * (self.tick_segundos or 15), 60) + return (timezone.now() - self.visto_em).total_seconds() < janela diff --git a/sapl/integracao_hub/painel.py b/sapl/integracao_hub/painel.py index f572ce34a..627da6f44 100644 --- a/sapl/integracao_hub/painel.py +++ b/sapl/integracao_hub/painel.py @@ -41,8 +41,9 @@ from django.http import HttpResponseRedirect from django.urls import reverse from django.views.generic import TemplateView, View -from .models import (DocumentoParaAssinatura, MateriaComFalhaMaterializacao, - MateriaParaMaterializar, PassadaMaterializacao) +from .models import (BatimentoLaco, DocumentoParaAssinatura, + MateriaComFalhaMaterializacao, MateriaParaMaterializar, + PassadaMaterializacao) logger = logging.getLogger(__name__) @@ -108,6 +109,10 @@ class PainelMaterializacaoView(PermissionRequiredMixin, TemplateView): # Fila de prioridade (ADR 0014): matérias marcadas pelo evento # aguardando o tick. Cresce e não esvazia = o laço não está de pé. 'na_fila': MateriaParaMaterializar.objects.count(), + # Batimento do laço (ADR 0015): a resposta de "está rodando?" sem + # shell. Morto aqui + fila crescendo = autossupervisão também + # falhou (aí sim é caso de shell). + 'batimento': BatimentoLaco.objects.first(), # A configuração que decide o sucesso da conversão fica na tela # porque foi exatamente ela a causa do incidente de 25/08 — e lê-la # exigia shell. diff --git a/sapl/integracao_hub/templates/integracao_hub/painel_materializacao.html b/sapl/integracao_hub/templates/integracao_hub/painel_materializacao.html index 995323369..6bc8ae62b 100644 --- a/sapl/integracao_hub/templates/integracao_hub/painel_materializacao.html +++ b/sapl/integracao_hub/templates/integracao_hub/painel_materializacao.html @@ -43,6 +43,28 @@ {% endif %} +
+ {% if batimento %} + {% if batimento.fresco %} + + Laço automático vivo (pid {{ batimento.pid }}, + último batimento {{ batimento.visto_em }}). + + {% else %} + + Laço automático SEM batimento desde + {{ batimento.visto_em }} — a autossupervisão vai ressuscitá-lo no + próximo acesso ao sistema; se esta mensagem persistir, o respawn + está falhando (ver sapl.log). + + {% endif %} + {% else %} + + Laço automático nunca bateu — sobe sozinho no + primeiro acesso após o deploy desta versão. + + {% endif %} +
{{ alvos_materializados }} documento(s) já materializado(s) e disponível(is) para assinatura. diff --git a/sapl/integracao_hub/tests/test_autossupervisao.py b/sapl/integracao_hub/tests/test_autossupervisao.py new file mode 100644 index 000000000..dc80609be --- /dev/null +++ b/sapl/integracao_hub/tests/test_autossupervisao.py @@ -0,0 +1,161 @@ +"""Autossupervisão do laço de materialização (ADR 0015). + +O contrato: o laço bate no banco a cada tick; o processo web ressuscita o laço +quando o batimento envelhece; dois laços nunca residem juntos; e um deploy faz +o laço se encerrar para renascer com o código novo. +""" +import os + +import pytest +from django.core.management import call_command +from django.utils import timezone + +from sapl.integracao_hub.middleware import laco_precisa_subir, subir_laco +from sapl.integracao_hub.models import BatimentoLaco, PassadaMaterializacao + +CMD = ('sapl.integracao_hub.management.commands' + '.materializar_pdfs_para_assinatura') + + +@pytest.fixture(autouse=True) +def base_url_configurada(settings): + settings.SAPL_INTERNAL_URL = 'http://sapl-interno:8000' + settings.SITE_URL = '' + + +def batimento(segundos_atras=0, pid=None): + agora = timezone.now() + return BatimentoLaco.objects.create( + pk=1, + visto_em=agora - timezone.timedelta(seconds=segundos_atras), + iniciado_em=agora, + pid=pid if pid is not None else os.getpid() + 1, + tick_segundos=15) + + +# --------------------------------------------------------------------------- +# O laço bate — inclusive é este batimento que o painel exibe +# --------------------------------------------------------------------------- + +@pytest.mark.django_db(transaction=False) +def test_modo_laco_grava_batimento(db, monkeypatch): + def sleep_que_interrompe(segundos): + raise KeyboardInterrupt + + monkeypatch.setattr(CMD + '.time.sleep', sleep_que_interrompe) + + with pytest.raises(KeyboardInterrupt): + call_command('materializar_pdfs_para_assinatura', intervalo=30) + + vivo = BatimentoLaco.objects.get(pk=1) + assert vivo.pid == os.getpid() + assert vivo.fresco + assert vivo.tick_segundos == 15 + + +@pytest.mark.django_db(transaction=False) +def test_passada_avulsa_nao_bate(db): + """O batimento mede a vida do LAÇO; passada manual não é laço.""" + call_command('materializar_pdfs_para_assinatura') + + assert not BatimentoLaco.objects.exists() + + +# --------------------------------------------------------------------------- +# Instância única: dois laços nunca residem juntos +# --------------------------------------------------------------------------- + +@pytest.mark.django_db(transaction=False) +def test_segundo_laco_sai_quando_ha_batimento_fresco_de_outro_pid(db): + batimento(segundos_atras=5) + + # Sem monkeypatch de sleep: se o guarda falhar, o laço entraria em loop + # infinito — o retorno imediato É a asserção. + call_command('materializar_pdfs_para_assinatura', intervalo=30) + + assert not PassadaMaterializacao.objects.exists() + + +@pytest.mark.django_db(transaction=False) +def test_laco_assume_quando_batimento_esta_velho(db, monkeypatch): + batimento(segundos_atras=600) + + def sleep_que_interrompe(segundos): + raise KeyboardInterrupt + + monkeypatch.setattr(CMD + '.time.sleep', sleep_que_interrompe) + + with pytest.raises(KeyboardInterrupt): + call_command('materializar_pdfs_para_assinatura', intervalo=30) + + assert BatimentoLaco.objects.get(pk=1).pid == os.getpid() + + +# --------------------------------------------------------------------------- +# Deploy: laço defasado se encerra para renascer com o código novo +# --------------------------------------------------------------------------- + +@pytest.mark.django_db(transaction=False) +def test_laco_sai_quando_o_codigo_muda_no_disco(db, monkeypatch): + getmtime_real = os.path.getmtime + contagem = {'n': 0} + + def getmtime_que_avanca(caminho): + # Só o arquivo do comando "muda de versão"; o resto do mundo segue + # normal — getmtime é global e outros códigos o usam no meio do teste. + if str(caminho).endswith('materializar_pdfs_para_assinatura.py'): + contagem['n'] += 1 + return float(contagem['n']) + return getmtime_real(caminho) + + monkeypatch.setattr(CMD + '.os.path.getmtime', getmtime_que_avanca) + + # Retorna sozinho (sem KeyboardInterrupt): a saída limpa É a asserção. + call_command('materializar_pdfs_para_assinatura', intervalo=30) + + +# --------------------------------------------------------------------------- +# O lado do processo web: decidir e ressuscitar +# --------------------------------------------------------------------------- + +@pytest.mark.django_db(transaction=False) +def test_precisa_subir_sem_batimento_ou_com_batimento_velho(db): + assert laco_precisa_subir() # nunca bateu + + b = batimento(segundos_atras=600) + assert laco_precisa_subir() # bateu ha 10min com tick de 15s + + b.visto_em = timezone.now() + b.save() + assert not laco_precisa_subir() # fresco + + +@pytest.mark.django_db(transaction=False) +def test_subir_laco_dispara_processo_desgarrado(db, monkeypatch): + chamadas = [] + + def popen_falso(comando, **kwargs): + chamadas.append((comando, kwargs)) + + monkeypatch.setattr( + 'sapl.integracao_hub.middleware.subprocess.Popen', popen_falso) + monkeypatch.setenv('MATERIALIZACAO_INTERVALO_SEGUNDOS', '120') + monkeypatch.setenv('MATERIALIZACAO_TICK_SEGUNDOS', '5') + + subir_laco() + + comando, kwargs = chamadas[0] + assert 'materializar_pdfs_para_assinatura' in comando + assert comando[comando.index('--intervalo') + 1] == '120' + assert comando[comando.index('--tick') + 1] == '5' + # Desgarrado do worker: sobrevive ao reload do gunicorn. + assert kwargs['start_new_session'] is True + + +def test_middleware_nao_liga_sob_pytest(): + """Spawnar laço residente por efeito de request de teste seria vazamento.""" + from django.core.exceptions import MiddlewareNotUsed + from sapl.integracao_hub.middleware import AutossupervisaoMaterializacao + + with pytest.raises(MiddlewareNotUsed): + AutossupervisaoMaterializacao(lambda request: None) diff --git a/sapl/settings.py b/sapl/settings.py index 2a1a6a1eb..6e703ea08 100644 --- a/sapl/settings.py +++ b/sapl/settings.py @@ -156,6 +156,10 @@ MIDDLEWARE = [ 'waffle.middleware.WaffleMiddleware', 'sapl.middleware.CheckWeakPasswordMiddleware', 'django_prometheus.middleware.PrometheusAfterMiddleware', + # Autossupervisão da materialização (ADR 0015): cada request confere (no + # máximo 1x/min) se o laço do PDF-alvo está vivo e o ressuscita se não — + # o SAPL de produção roda fora de docker e nada mais garante esse processo. + 'sapl.integracao_hub.middleware.AutossupervisaoMaterializacao', ] if DEBUG and 'runserver' in sys.argv: INSTALLED_APPS += ('debug_toolbar',) @@ -165,6 +169,12 @@ if DEBUG and 'runserver' in sys.argv: SITE_URL = config('SITE_URL', cast=str, default='') +# Desligue APENAS onde o laço de materialização é gerido por fora (supervisor/ +# systemd) e o respawn automático atrapalharia. O padrão ligado é o que +# garante a materialização sem depender de memória de operador (ADR 0015). +MATERIALIZACAO_AUTOSSUPERVISAO = config( + 'MATERIALIZACAO_AUTOSSUPERVISAO', cast=bool, default=True) + REST_FRAMEWORK = { "UNICODE_JSON": False, "DEFAULT_PARSER_CLASSES": (