From 62ec768d0c39325f6a99e5d2ebd3292eb3bc46f9 Mon Sep 17 00:00:00 2001 From: WanderMotta Date: Fri, 4 Sep 2026 22:00:34 -0300 Subject: [PATCH] =?UTF-8?q?Deploy:=20vari=C3=A1veis=20de=20ambiente=20conf?= =?UTF-8?q?igur=C3=A1veis=20+=20docs=20dos=20dois=20modos=20do=20Coolify?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TZ (com tzdata), GUNICORN_TIMEOUT e GUNICORN_LOG_LEVEL agora têm default no Dockerfile e podem ser sobrescritos via Coolify → Environment Variables ou .env (modelo em .env.example). TZ corrige o carimbo "exportada em" (sessao.marcar_exportada), que sem isso saía em UTC. Workers continua literal em -w 1, de propósito — não é variável. Cabeçalhos do Dockerfile e docker-compose.yml reescritos para descrever os dois modos de deploy no Coolify (Application/Dockerfile, em produção desde 04/09, e Docker Compose) e o caminho de volume que de fato funciona: Directory mount em /data/coolify/applications//clientes, dados enviados por scp da pasta ses-mg/. .gitattributes fixa LF também em Dockerfile, docker-compose.yml e .env.example — o autocrlf do Windows já tinha reescrito o Dockerfile para CRLF uma vez nesta máquina. Co-Authored-By: Claude Sonnet 5 --- .dockerignore | 3 +++ .env.example | 29 ++++++++++++++++++++ .gitattributes | 5 ++++ .gitignore | 3 +++ Dockerfile | 43 ++++++++++++++++++++++++------ docker-compose.yml | 66 +++++++++++++++++++++++++++++++--------------- 6 files changed, 120 insertions(+), 29 deletions(-) create mode 100644 .env.example diff --git a/.dockerignore b/.dockerignore index 45d984c..f93e65e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -17,3 +17,6 @@ scripts/backlog Dockerfile docker-compose.yml .dockerignore +.env +.env.example +.gitattributes diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a09ca3d --- /dev/null +++ b/.env.example @@ -0,0 +1,29 @@ +# Variáveis da Exportação da SD. Copie para .env (gitignored) para rodar com o +# compose; no Coolify em modo Application, cadastre em Environment Variables. +# Tudo tem default no Dockerfile — só declare o que quer mudar. + +# ── Lidas DENTRO do container (valem nos dois modos do Coolify) ────────────── + +# Porta do gunicorn dentro do container. No Coolify, a porta exposta da app +# tem de ser a mesma. +PORT=5000 + +# Fuso do carimbo "exportada em" (sessao.marcar_exportada usa datetime.now()). +TZ=America/Sao_Paulo + +# Segundos por request. Upload + análise de uma SD leva bem menos que isso; +# subir só se o painel mostrar "WORKER TIMEOUT". +GUNICORN_TIMEOUT=120 + +# debug | info | warning | error +GUNICORN_LOG_LEVEL=info + +# ── Lidas pelo COMPOSE ao montar volumes (só no Modo 2 / local) ───────────── +# No Modo 1 (Application) não têm efeito: o volume é o Directory mount da UI. + +# Pasta do host com os dados do cliente (tem de conter ses-mg/contrato/itens.yaml). +# Fora da pasta que o Coolify clona — ela é recriada a cada deploy. +#CLIENTES_DIR=/dados/exporta-sd/clientes + +# Saída da linha de comando (exporta_sd.py --dir). Opcional. +#DERIVADOS_DIR=/dados/exporta-sd/_derivados diff --git a/.gitattributes b/.gitattributes index 353dfe6..ecb2dc1 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,3 +3,8 @@ # autocrlf=true do Git no Windows reescreve o arquivo na próxima operação que # o toque e o build local passa a falhar mesmo com a imagem correta. *.sh text eol=lf +# Mesma razão para os arquivos de deploy: são lidos por ferramentas Linux e o +# autocrlf já reescreveu o Dockerfile para CRLF uma vez nesta máquina. +Dockerfile text eol=lf +docker-compose.yml text eol=lf +.env.example text eol=lf diff --git a/.gitignore b/.gitignore index b073b68..e91f99b 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,6 @@ clientes/ # Lock de sessão local do Claude Code — efêmero (PID, sessionId), não é config do projeto. .claude/scheduled_tasks.lock + +# Variáveis locais do compose — .env.example é o modelo versionado. +.env diff --git a/Dockerfile b/Dockerfile index 0f161f6..2ec7dab 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,10 +4,23 @@ # 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". +# 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 @@ -17,18 +30,31 @@ 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 ENV PYTHONUNBUFFERED=1 \ PYTHONDONTWRITEBYTECODE=1 \ LANG=C.UTF-8 \ LC_ALL=C.UTF-8 \ - PORT=5000 + PORT=5000 \ + TZ=America/Sao_Paulo \ + GUNICORN_TIMEOUT=120 \ + GUNICORN_LOG_LEVEL=info 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 \ + && 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. @@ -67,8 +93,9 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ # é 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. +# 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 120 --access-logfile - --error-logfile -"] +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 -"] diff --git a/docker-compose.yml b/docker-compose.yml index 6e106ee..5c7f4a7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,47 +1,71 @@ -# Deploy em VPS pelo painel Coolify (recurso do tipo "Docker Compose"). +# Deploy da Exportação da SD — a imagem é a do Dockerfile; este arquivo é UM dos +# dois jeitos de subi-la no Coolify, e serve também para rodar local. # -# ANTES do primeiro deploy, ponha os dados do cliente em um caminho FIXO do -# host, FORA da pasta que o Coolify clona — ele re-clona a cada deploy, e -# clientes/ está no .gitignore, então o clone nunca traz esses dados: +# ── MODO 1 · Application (Dockerfile) — o que está em produção (2026-09-04) ── # -# scp -r clientes/ usuario@vps:/dados/exporta-sd/clientes +# Neste modo o Coolify IGNORA este arquivo por completo: builda o Dockerfile e +# configura tudo pela interface. O que o volume e as variáveis daqui dizem tem +# de ser reproduzido lá: # -# Não precisa de chown manual: o entrypoint.sh da imagem roda como root no -# início do container e ajusta o dono do volume para o usuário `sd` antes de -# derrubar privilégio e subir o gunicorn — ver entrypoint.sh e o ENTRYPOINT -# no Dockerfile. (Uma reconsequência: cada `scp` novo devolve os arquivos ao -# usuário do ssh no host; some sozinho no próximo restart do container.) -# Depois, no Coolify → Environment Variables, aponte o volume para lá: +# Persistent storage → Add mount → "Directory mount" +# Source Path: /data/coolify/applications//clientes +# (o próprio Coolify sugere esse caminho; ele sobrevive +# a redeploy — é a pasta de storage da app, não o clone) +# Destination Path: /app/clientes +# Mesmo esquema para _derivados, só se for usar a linha de comando. # -# CLIENTES_DIR=/dados/exporta-sd/clientes -# DERIVADOS_DIR=/dados/exporta-sd/_derivados # só se usar a linha de comando +# Dados: a pasta do host nasce VAZIA. Copie o conteúdo para dentro dela — +# aponte para "ses-mg", não para "clientes", ou o nível duplica +# (.../clientes/clientes/ses-mg): # -# Sem essas variáveis, o padrão ./clientes cai dentro da pasta do clone, o -# compose cria o diretório VAZIO sem avisar e o primeiro upload morre com -# "Arquivo canônico ausente". +# scp -r "D:/Exporta-SD/clientes/ses-mg" usuario@vps:/data/coolify/applications//clientes/ # -# Resto do painel: porta exposta 5000, réplicas 1 (ver o comentário de -w 1 no -# Dockerfile), e o domínio o Coolify publica via Traefik. +# e confira no host: ls /data/coolify/applications//clientes/ses-mg/contrato/ +# Tem de listar itens.yaml E linhas-os.yaml — sem o segundo, o fallback da +# linha de OS desliga e `linha_os` vira pendência só no VPS. +# +# Variáveis: aba Environment Variables (nomes e defaults em .env.example). +# Réplicas: 1 (ver o comentário de -w 1 no Dockerfile). +# +# ── MODO 2 · Docker Compose (ou local) ────────────────────────────────────── +# +# O Coolify lê este arquivo. Ponha os dados num caminho fixo do host, fora da +# pasta que ele clona, e aponte CLIENTES_DIR para lá (Environment Variables no +# Coolify, ou um .env ao lado deste arquivo — copie .env.example). Sem a +# variável, o padrão ./clientes cai dentro do clone, o compose cria o +# diretório VAZIO sem avisar e o primeiro upload morre com "Arquivo canônico +# ausente". +# +# Nos dois modos não há chown manual: o entrypoint.sh roda como root ao subir +# o container, ajusta o dono do volume para o usuário `sd` e só então derruba +# privilégio e inicia o gunicorn. Um scp novo devolve os arquivos ao usuário do +# ssh; resolve sozinho no próximo restart. services: exporta-sd: build: context: . dockerfile: Dockerfile + image: exporta-sd:local # Sem `ports:` de propósito: no Coolify o Traefik alcança o container pela # rede interna. `expose` publica a porta só para dentro dessa rede. # Para rodar fora do Coolify, troque por: ports: ["5000:5000"]. expose: - "5000" + # Só ESPELHA os defaults do Dockerfile — o valor de verdade mora lá (ENV e + # ${VAR:-default} no CMD), porque no Modo 1 este bloco nem é lido. Aqui a + # única função é deixar o operador trocar pelo .env sem editar YAML. environment: - PORT: "5000" + PORT: ${PORT:-5000} + TZ: ${TZ:-America/Sao_Paulo} + GUNICORN_TIMEOUT: ${GUNICORN_TIMEOUT:-120} + GUNICORN_LOG_LEVEL: ${GUNICORN_LOG_LEVEL:-info} volumes: # OBRIGATÓRIO. O app lê clientes/ses-mg/contrato/itens.yaml a cada # análise (e contrato/linhas-os.yaml, se existir, como fallback da linha # de OS) e grava SD.md em clientes/ses-mg/projetos/. O caminho no # container é fixo: caminhos.py resolve clientes/ a partir da raiz do - # código, que é o WORKDIR /app. Sem esta montagem, a tela inicial abre - # e o primeiro upload falha com "Arquivo canônico ausente". + # código, que é o WORKDIR /app. - ${CLIENTES_DIR:-./clientes}:/app/clientes # Saída da linha de comando (exporta_sd.py --dir _derivados/export). # A aplicação web entrega o JSON pelo download, não por aqui.