# 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". E sem linhas-os.yaml no volume, o VPS se # comporta diferente do local: o fallback desliga e `linha_os` vira pendência. # # DOIS MODOS de deploy no Coolify — a imagem é a mesma, muda onde se configura: # # 1. Application (Dockerfile) — o que está em produção (2026-09-04). O Coolify # IGNORA o docker-compose.yml. Volume: Persistent storage → Add mount → # "Directory mount", Source /data/coolify/applications//clientes # (o Coolify sugere esse caminho; ele sobrevive a redeploy) → Destination # /app/clientes. Variáveis: aba Environment Variables. Réplicas: 1. # 2. Docker Compose — usa o docker-compose.yml; volume e variáveis vêm de lá. # # Por isso TODO default de variável mora AQUI (ENV + ${VAR:-default} no CMD), # e o compose só espelha: variável que existe só no compose não existe no VPS. # # 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. # TZ: sessao.marcar_exportada carimba datetime.now() na tela ("exportada em"); # sem isto o container está em UTC e o horário sai 3h adiantado. # # Variáveis que o operador pode sobrescrever (Coolify → Environment Variables, # ou .env com o compose — ver .env.example). Os defaults valem nos dois modos: # PORT porta do gunicorn dentro do container # TZ fuso do carimbo de exportação # GUNICORN_TIMEOUT segundos por request (upload + análise da SD) # GUNICORN_LOG_LEVEL debug | info | warning | error # APP_USUARIO e-mail da credencial única da tela de login # APP_SENHA senha dessa credencial # SECRET_KEY assina o cookie de sessão — SEM default aqui de # propósito: chave fixa assada na imagem é pior que # nenhuma (vale para toda cópia dela). Sem a variável, o # servidor.py sorteia uma por processo e o login cai a # cada restart; cadastre no Coolify para o login durar. ENV PYTHONUNBUFFERED=1 \ PYTHONDONTWRITEBYTECODE=1 \ LANG=C.UTF-8 \ LC_ALL=C.UTF-8 \ PORT=5000 \ TZ=America/Sao_Paulo \ GUNICORN_TIMEOUT=120 \ GUNICORN_LOG_LEVEL=info \ APP_USUARIO=admin@iasis.com.br \ APP_SENHA=admin123 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. # tzdata: zoneinfo para o TZ acima ter efeito (a slim não garante que venha). RUN apt-get update \ && apt-get install -y --no-install-recommends gosu tzdata \ && 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 — e é por isso que o número # de workers NÃO é variável de ambiente: não existe valor certo além de 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 ${GUNICORN_TIMEOUT:-120} --log-level ${GUNICORN_LOG_LEVEL:-info} --access-logfile - --error-logfile -"]