Files
Exporta-SD/Dockerfile
T
wanderandClaude Fable 5.1 a4b1374fad Linha de OS: tabela linhas-os.yaml como fallback quando o SD.md não declara
Decisão de 2026-09-04, revendo a de 2026-09-03 (sem de-para): o .md continua
mandando, e a tabela contrato/linhas-os.yaml (OS Mãe × linha × item) só entra
quando o entregável não declara `linha_os`. Linha declarada vai como está e não
é conferida contra a tabela; existência/item/status seguem sendo do banco.

- caminhos.LINHAS_OS; regras_sd: Canonico(itens, linhas_os), carregar_linhas_os
  (arquivo ausente = fallback desligado), preparar_linhas_os (valida: formato,
  linha repetida, dois `padrao`, `padrao` não booleano) e linha_da_tabela.
- exporta_sd: ausente → padrão do item; 2+ candidatas sem padrão → pendência
  nomeando-as; item fora da tabela → pendência. CLI informa quantas linhas
  vieram da tabela.
- app: sessao.adotar_linhas_da_tabela grava a linha no frontmatter da sessão
  com comentário de origem, para o Salvar SD.md deixar a SD autodeclarada;
  a tela marca `tabela` e mostra a data da tabela.
- testes: SD18, SD27, SD31–36 passam a exportar de saída; SD10 só PO+objetivo;
  casos sintéticos para tabela ausente, ambígua, sem o item e malformada.
- deploy: linhas-os.yaml vive em clientes/ (gitignored) — vai por scp ao volume.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 20:22:15 -03:00

75 lines
3.4 KiB
Docker

# Imagem da aplicação de Exportação da SD — deploy em VPS via painel Coolify.
#
# O que NÃO entra na imagem, e por que: clientes/. O app lê
# clientes/ses-mg/contrato/itens.yaml em tempo de execução (regras_sd.carregar_canonico),
# consulta contrato/linhas-os.yaml se existir (fallback da linha de OS; ausente,
# `linha_os` é exigido no SD.md) e grava SD.md de volta em
# clientes/ses-mg/projetos/ (escrita_sd.gravar). Esses
# são dados do cliente, versionados fora do git — entram por VOLUME montado em
# /app/clientes. Sem esse volume, a imagem sobe e a tela inicial responde, mas o
# primeiro upload falha com "Arquivo canônico ausente".
#
# Build local: docker build -t exporta-sd .
# Run local: docker run --rm -p 5000:5000 -v "$PWD/clientes:/app/clientes" exporta-sd
FROM python:3.13-slim
# PYTHONUNBUFFERED: log do gunicorn sai na hora no painel do Coolify.
# LANG/LC_ALL em UTF-8: identificadores de SD trazem "·" (P2·SD8) e o
# escrita_sd monta nome de arquivo a partir deles.
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
LANG=C.UTF-8 \
LC_ALL=C.UTF-8 \
PORT=5000
WORKDIR /app
# gosu: o entrypoint precisa iniciar como root (só root faz chown no volume
# recém-montado) e depois derrubar privilégio para `sd` antes do gunicorn.
RUN apt-get update \
&& apt-get install -y --no-install-recommends gosu \
&& rm -rf /var/lib/apt/lists/*
# Dependências em camada própria: mudar código não reinstala pacote.
# requirements.txt é o mesmo do venv Windows; o gunicorn fica separado porque
# é só do deploy (no Windows ele não instala).
COPY requirements.txt requirements-deploy.txt ./
RUN pip install --no-cache-dir -r requirements.txt -r requirements-deploy.txt
# Só o código. O resto (clientes/, backlog, .md da raiz) está no .dockerignore.
COPY scripts/ ./scripts/
COPY app/ ./app/
# Usuário sem privilégio — quem roda o gunicorn, via entrypoint.sh.
RUN mkdir -p /app/clientes /app/_derivados \
&& useradd --create-home --uid 10001 sd \
&& chown -R sd:sd /app
# chmod aqui, e não só no arquivo do host: git em Windows não carrega bit de
# execução, então sem isso o entrypoint chega ao Coolify sem permissão de
# executar.
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Container começa como root DE PROPÓSITO — é o entrypoint.sh que faz o
# chown do volume recém-montado e derruba para `sd` antes do gunicorn. Ver
# entrypoint.sh.
ENTRYPOINT ["/entrypoint.sh"]
EXPOSE 5000
# GET / responde 200 sem sessão (renderiza a tela de upload).
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD python -c "import urllib.request,os,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:'+os.environ.get('PORT','5000')+'/',timeout=4).status==200 else 1)"
# -w 1 NÃO é ajuste de desempenho, é requisito de correção: o estado da sessão
# é a global ATUAL no processo do servidor.py (uma SD por vez, em memória).
# Com dois workers, dois requests do MESMO usuário caem em processos
# diferentes e a sessão desaparece de forma aleatória. Pelo mesmo motivo, o
# número de réplicas no Coolify tem de ficar em 1.
#
# --chdir app + servidor:app: o servidor.py insere ../scripts no sys.path a
# partir de __file__, então funciona igual sob gunicorn e sob `python app/servidor.py`.
CMD ["sh", "-c", "exec gunicorn --chdir app servidor:app -w 1 -b 0.0.0.0:${PORT:-5000} --timeout 120 --access-logfile - --error-logfile -"]