From dea510c3b40915e7ebc2cb704f2b1faa8b201a15 Mon Sep 17 00:00:00 2001 From: camilafagundes Date: Thu, 15 Oct 2026 20:39:29 -0300 Subject: [PATCH] =?UTF-8?q?Imagem=20Docker=20e=20compose=20reproduz=C3=ADv?= =?UTF-8?q?eis=20para=20homologa=C3=A7=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Não existia nenhum artefato de deploy: sem Dockerfile, sem compose, sem pin de Node. O runbook pedia `npm run start` e `npm run worker` à mão, o que não é um ambiente reproduzível, e o worker dependia de `tsx`, uma devDependency, que `npm ci --omit=dev` removeria. - Dockerfile multistage. Web e worker saem da mesma imagem: mesmo código, mesmas dependências, só o subcomando difere. - `tsx` foi para `dependencies`. Conferido em diretório limpo: os 13 pacotes externos que src e scripts usam existem no install de produção. - A sobreposição das duas árvores de node_modules é segura e foi medida: 318 pacotes em produção contra 153 traçados, zero divergência de versão entre os compartilhados, e 3 que existem só na traçada, que o COPY mescla sem remover. O package.json do standalone não é sobrescrito. - Entrypoint com subcomandos, que garante `node --import tsx` em todos os scripts. Usar `node` direto convidaria de volta o defeito do SIGTERM. - Healthcheck de liveness na web e de heartbeat no worker. - Compose local reproduz a topologia com Postgres e Redis de verdade. Compose de homologação não declara Postgres (é o banco gerenciado), usa Redis isolado com senha e volume próprios, publica porta só em 127.0.0.1 e tem nome de projeto, rede e volume exclusivos. Nenhum serviço de outro aplicativo do ecossistema é declarado, tocado ou reiniciado. - `.env.staging.example` sem segredo, `.nvmrc`, `engines` e `packageManager`. - `config/staging-seed.json` e `.env.staging` no gitignore. Co-Authored-By: Claude Opus 5 --- .dockerignore | 20 ++++++ .env.example | 89 +++++++++++++++-------- .env.staging.example | 85 ++++++++++++++++++++++ .gitignore | 3 + .nvmrc | 1 + Dockerfile | 86 ++++++++++++++++++++++ config/staging-seed.example.json | 43 +++++++++++ docker-compose.staging.yml | 118 +++++++++++++++++++++++++++++++ docker-compose.yml | 111 +++++++++++++++++++++++++++++ docker/entrypoint.sh | 47 ++++++++++++ docker/worker-health.mjs | 23 ++++++ 11 files changed, 598 insertions(+), 28 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.staging.example create mode 100644 .nvmrc create mode 100644 Dockerfile create mode 100644 config/staging-seed.example.json create mode 100644 docker-compose.staging.yml create mode 100644 docker-compose.yml create mode 100644 docker/entrypoint.sh create mode 100644 docker/worker-health.mjs diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f93a925 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,20 @@ +node_modules +.next +.data +dist +coverage +.git +.github +.cursor +docs +tests +_prompts_tmp +_escopo_extraido.txt +*.tsbuildinfo +.env +.env.* +!.env.staging.example +config/staging-seed.json +Dockerfile +docker-compose*.yml +README.md diff --git a/.env.example b/.env.example index e926e61..181d10f 100644 --- a/.env.example +++ b/.env.example @@ -1,44 +1,77 @@ -# Identidade Elo Pro (Supabase Auth). Sem estes valores o módulo não autentica em produção. +# Desenvolvimento local. Para homologação use .env.staging.example. +# Confira a qualquer momento com: npm run verify:config + +# --- Ambiente lógico ------------------------------------------------------- +# development | staging | production. Independente de NODE_ENV. +ADAPTER_ENV=development +# Prefixo das chaves externas (Redis). Separa este aplicativo e este ambiente. +ADAPTER_NAMESPACE=adaptador-planilhas + +# --- Sessão local sem senha (SOMENTE development) -------------------------- +# Em staging e produção o provedor de desenvolvimento não existe no caminho de +# código, e ADAPTER_DEV_AUTH=1 faz o start ser recusado. +ADAPTER_DEV_AUTH=1 +ADAPTER_DEV_AUTH_SECRET=troque-este-segredo-local + +# --- Identidade Supabase (opcional em development) ------------------------- NEXT_PUBLIC_SUPABASE_URL= NEXT_PUBLIC_SUPABASE_ANON_KEY= # Service role: somente servidor. Nunca prefixar com NEXT_PUBLIC_. SUPABASE_SERVICE_ROLE_KEY= +# Bucket privado. O valor literal "public" é recusado. SUPABASE_STORAGE_BUCKET= +# Login próprio de homologação com Supabase Auth real. Só vale em staging. +ADAPTER_STAGING_AUTH=0 -# PostgreSQL do Elo Pro. Obrigatório em produção. +# --- Banco ---------------------------------------------------------------- +# Ausente em development usa PGlite em .data/pglite. +# Em staging e produção é obrigatória: não há fallback local. DATABASE_URL= +# Decisão explícita, obrigatória fora de development. +# 1 = criar o shim local de identidade (profiles, workspaces, workspace_members) +# 0 = o schema do Elo Pro já existe; não recriar +ADAPTER_APPLY_IDENTITY_SHIM=1 -# Redis. Obrigatório em produção para lock. Sem REDIS_URL o processamento recusa executar. +# --- Redis ---------------------------------------------------------------- +# Ausente em development usa coordenação em memória. +# Em staging e produção é obrigatória: sem ela o processamento é recusado. REDIS_URL= -# Sentry. Sem DSN a instrumentação fica inerte. -SENTRY_DSN= -NEXT_PUBLIC_SENTRY_DSN= -SENTRY_ENVIRONMENT=development -SENTRY_RELEASE= +# --- Processamento -------------------------------------------------------- +# inline processa no mesmo processo (somente development). +# worker enfileira no Redis e no Postgres. Obrigatório fora de development. +ADAPTER_JOB_MODE=inline +# TTL de fontes e saídas, em segundos (1 a 604800). Obrigatório fora de +# development; ausente em development usa 3600. +SPREADSHEET_TEMP_TTL_SECONDS=3600 +# Teto de um processamento, em milissegundos. Ausente usa 120000. +SPREADSHEET_JOB_TIMEOUT_MS= +ADAPTER_WORKER_TICK_MS=2000 +ADAPTER_WORKER_BATCH=20 -# Login central do ecossistema. O Adaptador não tem formulário de senha. +# --- Login central do ecossistema ----------------------------------------- +# O Adaptador não tem formulário de senha própria. ELO_PRO_LOGIN_URL= # URL pública deste aplicativo. Usada para montar returnTo no mesmo host. -ELO_PRO_APP_URL= -# Hosts adicionais aceitos no returnTo, separados por vírgula. Caminho relativo sempre é preferido. +ELO_PRO_APP_URL=http://localhost:3000 +# Hosts adicionais aceitos no returnTo, separados por vírgula. AUTH_ALLOWED_RETURN_HOSTS= +# Cookie de workspace ativo, definido pela plataforma: elo_pro_workspace_id. +# É preferência, não autorização: o servidor revalida a associação sempre. -# Cookie de workspace ativo definido pela plataforma Elo Pro. -# Nome lido pelo servidor: elo_pro_workspace_id +# --- Observabilidade ------------------------------------------------------ +# Sem DSN a instrumentação fica inerte. +SENTRY_DSN= +NEXT_PUBLIC_SENTRY_DSN= +# Ausente cai em ADAPTER_ENV. +SENTRY_ENVIRONMENT= +SENTRY_RELEASE= +# Upload de sourcemap só acontece com os três presentes no build. +SENTRY_ORG= +SENTRY_PROJECT= +SENTRY_AUTH_TOKEN= -# TTL de fontes e saídas, em segundos. Obrigatório em produção. Inteiro de 1 a 604800. -# Em dev/teste, a ausência usa 3600. O número de produto continua configurável. -SPREADSHEET_TEMP_TTL_SECONDS= - -# Tempo máximo de um processamento, em milissegundos. Ausente usa 120000. -SPREADSHEET_JOB_TIMEOUT_MS= - -# inline = processa no mesmo processo (somente dev/teste). worker = enfileira no Redis e no Postgres. -# Em produção, inline é recusado. -ADAPTER_JOB_MODE=worker - -# Sessão local SEM senha, só fora de produção, para piloto sem o host Elo Pro. -# Produção recusa ADAPTER_DEV_AUTH=1. -ADAPTER_DEV_AUTH=0 -ADAPTER_DEV_AUTH_SECRET= +# --- Testes com serviços reais (npm run test:services) -------------------- +# Banco descartável: a suíte recria o schema public. +ADAPTER_TEST_DATABASE_URL= +ADAPTER_TEST_REDIS_URL= diff --git a/.env.staging.example b/.env.staging.example new file mode 100644 index 0000000..d9ceb1e --- /dev/null +++ b/.env.staging.example @@ -0,0 +1,85 @@ +# Homologação — hml-ada.elopro.app +# Copie para .env.staging no servidor e preencha. NÃO versione .env.staging. +# Confira com: npm run verify:config + +# --- Ambiente lógico ------------------------------------------------------- +# development | staging | production. Em homologação é sempre staging, mesmo +# com NODE_ENV=production (o build é o otimizado). +ADAPTER_ENV=staging +# Prefixo das chaves externas. Separa este aplicativo dos demais do ecossistema +# no Redis compartilhado e separa homologação de produção. +ADAPTER_NAMESPACE=adaptador-planilhas + +# --- Identidade (Supabase Auth do projeto de HOMOLOGAÇÃO) ------------------ +# Projeto Supabase próprio de homologação. Nunca o projeto de produção: +# credencial administrativa não é compartilhada entre aplicações nem ambientes. +NEXT_PUBLIC_SUPABASE_URL= +NEXT_PUBLIC_SUPABASE_ANON_KEY= +# Somente servidor. Nunca prefixar com NEXT_PUBLIC_. +SUPABASE_SERVICE_ROLE_KEY= +# Bucket PRIVADO e exclusivo deste aplicativo neste ambiente. +# Confira com: npm run check:bucket +SUPABASE_STORAGE_BUCKET=adaptador-planilhas-hml + +# Login próprio de homologação com Supabase Auth real (com senha, emitida e +# validada pelo Supabase). O dev auth sem senha NÃO funciona aqui. +# As contas são criadas pela operação no painel do Supabase de homologação. +ADAPTER_STAGING_AUTH=1 +# Alternativa a ADAPTER_STAGING_AUTH: encaminhar ao SSO do Elo Pro quando existir. +ELO_PRO_LOGIN_URL= +ELO_PRO_APP_URL=https://hml-ada.elopro.app +AUTH_ALLOWED_RETURN_HOSTS=hml-ada.elopro.app + +# --- Banco ---------------------------------------------------------------- +# Banco de HOMOLOGAÇÃO. Nunca o de produção. +DATABASE_URL= +# Decisão explícita e obrigatória: +# 1 = criar o shim local de identidade (profiles, workspaces, workspace_members) +# 0 = o schema do Elo Pro já existe nesse banco; não recriar +# Rode `npm run migrate:check` antes de decidir. +ADAPTER_APPLY_IDENTITY_SHIM=1 + +# --- Redis (contêiner isolado deste aplicativo) --------------------------- +# Porta publicada só em 127.0.0.1 pelo docker-compose.staging.yml. +ADAPTER_REDIS_PASSWORD= +ADAPTER_REDIS_PORT=56380 +REDIS_URL=redis://:SENHA_DO_REDIS@redis:6379 + +# --- Processamento -------------------------------------------------------- +# worker é obrigatório fora de development. inline é recusado no start. +ADAPTER_JOB_MODE=worker +# Vida dos arquivos temporários, em segundos (1 a 604800). Obrigatório. +SPREADSHEET_TEMP_TTL_SECONDS=86400 +# Teto de um processamento, em milissegundos. Ausente usa 120000. +SPREADSHEET_JOB_TIMEOUT_MS=120000 +ADAPTER_WORKER_TICK_MS=2000 +ADAPTER_WORKER_BATCH=20 +# Carência do desligamento gracioso: espera o tick corrente liberar o lock. +# Precisa ser MENOR que o stop_grace_period do compose (30s). +ADAPTER_WORKER_GRACE_MS=20000 +# Heartbeat lido pelo healthcheck do contêiner do worker. +ADAPTER_WORKER_HEARTBEAT=/tmp/adaptador-worker-heartbeat + +# --- Observabilidade ------------------------------------------------------ +# Projeto Sentry separado ou ambiente separado no mesmo projeto. +SENTRY_DSN= +NEXT_PUBLIC_SENTRY_DSN= +SENTRY_ENVIRONMENT=staging +# Preenchido pelo pipeline com o SHA do commit. +SENTRY_RELEASE= + +# --- Imagem e portas do compose ------------------------------------------- +ADAPTER_IMAGE=ghcr.io/elopro/adaptador-planilhas:staging +ADAPTER_WEB_PORT=3110 + +# --- Smoke (fase 2, opcional; nunca comitar valores) ---------------------- +# Conta de teste criada no painel do Supabase de HOMOLOGAÇÃO. Sem estas duas, +# a fase autenticada do smoke é reportada como PENDENTE, nunca aprovada. +ADAPTER_SMOKE_URL=https://hml-ada.elopro.app +ADAPTER_SMOKE_EMAIL= +ADAPTER_SMOKE_PASSWORD= +ADAPTER_SMOKE_WORKSPACE= + +# --- Nunca definir em homologação ----------------------------------------- +# ADAPTER_DEV_AUTH: o start é recusado com valor 1 fora de development. +# ADAPTER_DEV_AUTH_SECRET: não é usado fora de development. diff --git a/.gitignore b/.gitignore index 520aaa2..cb3b93f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ dist coverage .env .env.local +.env.staging +.env.production *.tsbuildinfo _prompts_tmp _escopo_extraido.txt +config/staging-seed.json diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..9e2934a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24.11.1 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c25061f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,86 @@ +# Imagem única para os dois processos do módulo: web (Next standalone) e worker. +# Rodar os dois do mesmo artefato garante que web e worker compartilhem +# exatamente o mesmo código e as mesmas dependências em homologação. + +# --------------------------------------------------------------------------- +# Base +# --------------------------------------------------------------------------- +FROM node:24-bookworm-slim AS base +ENV NEXT_TELEMETRY_DISABLED=1 +WORKDIR /app + +# --------------------------------------------------------------------------- +# Dependências de produção. Inclui `tsx`, que o worker e os scripts +# operacionais usam em runtime — por isso `tsx` está em `dependencies`. +# `--ignore-scripts` é seguro aqui: nenhuma dependência de produção depende de +# postinstall, e o binário do esbuild vem do pacote de plataforma. +# --------------------------------------------------------------------------- +FROM base AS deps-prod +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev --ignore-scripts && npm cache clean --force + +# --------------------------------------------------------------------------- +# Dependências completas e build +# --------------------------------------------------------------------------- +FROM base AS builder +COPY package.json package-lock.json ./ +RUN npm ci --ignore-scripts +COPY tsconfig.json next.config.ts postcss.config.mjs eslint.config.mjs next-env.d.ts ./ +COPY src ./src +COPY migrations ./migrations +COPY scripts ./scripts +# Release do Sentry: o SHA do commit entra no bundle do cliente e no servidor, +# para o stack trace do evento apontar o código certo. Não é segredo. +ARG SENTRY_RELEASE="" +ENV SENTRY_RELEASE=$SENTRY_RELEASE +ENV NEXT_PUBLIC_SENTRY_RELEASE=$SENTRY_RELEASE +# O build não recebe segredo: `collectConfigIssues` é ignorado na fase de build +# e a validação acontece no start do container. +ENV NEXT_TELEMETRY_DISABLED=1 +RUN npm run build + +# --------------------------------------------------------------------------- +# Runtime +# --------------------------------------------------------------------------- +FROM base AS runner +ARG SENTRY_RELEASE="" +ENV NODE_ENV=production +ENV PORT=3000 +ENV HOSTNAME=0.0.0.0 +ENV SENTRY_RELEASE=$SENTRY_RELEASE +ENV ADAPTER_WORKER_HEARTBEAT=/tmp/adaptador-worker-heartbeat + +RUN groupadd --system --gid 1001 adaptador \ + && useradd --system --uid 1001 --gid adaptador adaptador + +# 1) node_modules de produção primeiro. Traz `tsx` para o worker e os scripts. +COPY --from=deps-prod --chown=adaptador:adaptador /app/node_modules ./node_modules + +# 2) Saída autocontida do Next por cima. Onde um pacote existe nas duas árvores, +# a versão traçada do build é a que vale para o servidor. Conferido: as duas +# árvores vêm do mesmo lockfile e não divergem em nenhuma versão; a árvore +# traçada tem 3 pacotes que não ficam no topo da de produção +# (@emnapi/runtime, isarray, supports-color), e o COPY mescla sem removê-los. +# O package.json do standalone NÃO é sobrescrito: é o que o server.js espera. +COPY --from=builder --chown=adaptador:adaptador /app/.next/standalone ./ +COPY --from=builder --chown=adaptador:adaptador /app/.next/static ./.next/static + +# 3) Arquivos lidos em runtime: migrations pelo migrate, src e scripts pelo +# worker, tsconfig pelos aliases `@/` do tsx. +COPY --chown=adaptador:adaptador migrations ./migrations +COPY --chown=adaptador:adaptador scripts ./scripts +COPY --chown=adaptador:adaptador src ./src +COPY --chown=adaptador:adaptador tsconfig.json ./ +COPY --chown=adaptador:adaptador docker ./docker + +USER adaptador +EXPOSE 3000 + +# Liveness não toca dependência externa: distingue processo vivo de dependência +# fora do ar, que é o papel de /api/health/ready. O contêiner do worker troca +# este healthcheck por `worker-health` no compose. +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/api/health/live').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + +ENTRYPOINT ["/app/docker/entrypoint.sh"] +CMD ["web"] diff --git a/config/staging-seed.example.json b/config/staging-seed.example.json new file mode 100644 index 0000000..df1c47a --- /dev/null +++ b/config/staging-seed.example.json @@ -0,0 +1,43 @@ +{ + "_comentario": [ + "Modelo do seed de homologacao. Copie para config/staging-seed.json e preencha.", + "users[].id e o UUID do usuario no Supabase Auth do projeto de homologacao.", + "Crie as contas no painel do Supabase: este arquivo nao guarda senha nem e-mail.", + "workspaces[].id pode ser qualquer UUID v4 estavel escolhido pela operacao.", + "Nao versione config/staging-seed.json." + ], + "workspaces": [ + { + "id": "00000000-0000-4000-8000-000000000001", + "name": "Homologacao Alpha", + "moduleEnabled": false, + "allowNonAdminModelUpload": false + }, + { + "id": "00000000-0000-4000-8000-000000000002", + "name": "Homologacao Beta", + "moduleEnabled": false + } + ], + "users": [ + { "id": "00000000-0000-4000-8000-0000000000a1", "displayName": "Owner de homologacao" }, + { "id": "00000000-0000-4000-8000-0000000000a2", "displayName": "Agente de homologacao" } + ], + "members": [ + { + "workspaceId": "00000000-0000-4000-8000-000000000001", + "userId": "00000000-0000-4000-8000-0000000000a1", + "role": "owner" + }, + { + "workspaceId": "00000000-0000-4000-8000-000000000001", + "userId": "00000000-0000-4000-8000-0000000000a2", + "role": "agent" + }, + { + "workspaceId": "00000000-0000-4000-8000-000000000002", + "userId": "00000000-0000-4000-8000-0000000000a2", + "role": "agent" + } + ] +} diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml new file mode 100644 index 0000000..0644ed0 --- /dev/null +++ b/docker-compose.staging.yml @@ -0,0 +1,118 @@ +# Homologação em hml-ada.elopro.app, no servidor compartilhado do ecossistema. +# +# Regras que este arquivo respeita, por construção: +# - não declara, não reinicia e não depende de nenhum serviço de outro +# aplicativo: rede, volume e nome de projeto são exclusivos deste módulo; +# - não expõe porta em 0.0.0.0: só 127.0.0.1, para o Nginx fazer o proxy; +# - Redis é um contêiner isolado deste aplicativo, com senha e volume próprios, +# nunca o Redis compartilhado dos outros aplicativos; +# - Postgres não é declarado aqui: é o banco gerenciado do Elo Pro, informado +# em DATABASE_URL. Nenhuma migration roda no start — é passo manual aprovado. +# +# docker compose -f docker-compose.staging.yml --env-file .env.staging up -d --build +# docker compose -f docker-compose.staging.yml --env-file .env.staging \ +# run --rm web migrate --dry-run + +name: adaptador-planilhas-hml + +services: + redis: + image: redis:7-alpine + restart: unless-stopped + command: + - "redis-server" + - "--requirepass" + - "${ADAPTER_REDIS_PASSWORD:?defina ADAPTER_REDIS_PASSWORD}" + - "--save" + - "900" + - "1" + - "--appendonly" + - "no" + - "--maxmemory" + - "256mb" + - "--maxmemory-policy" + - "noeviction" + ports: + - "127.0.0.1:${ADAPTER_REDIS_PORT:-56380}:6379" + volumes: + - redis-data:/data + healthcheck: + test: ["CMD-SHELL", "redis-cli -a \"$$REDIS_PASSWORD\" ping | grep -q PONG"] + interval: 10s + timeout: 5s + retries: 10 + environment: + REDIS_PASSWORD: ${ADAPTER_REDIS_PASSWORD} + networks: [adaptador-hml] + + web: + image: ${ADAPTER_IMAGE:-adaptador-planilhas:local} + build: + context: . + target: runner + restart: unless-stopped + command: ["web"] + env_file: + - .env.staging + environment: + ADAPTER_ENV: staging + NODE_ENV: production + PORT: "3000" + HOSTNAME: 0.0.0.0 + ports: + - "127.0.0.1:${ADAPTER_WEB_PORT:-3110}:3000" + depends_on: + redis: + condition: service_healthy + healthcheck: + test: + - CMD + - node + - -e + - "fetch('http://127.0.0.1:3000/api/health/live').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + interval: 30s + timeout: 5s + start_period: 20s + retries: 3 + logging: &logging + driver: json-file + options: + max-size: "10m" + max-file: "5" + networks: [adaptador-hml] + + worker: + image: ${ADAPTER_IMAGE:-adaptador-planilhas:local} + build: + context: . + target: runner + restart: unless-stopped + command: ["worker"] + env_file: + - .env.staging + environment: + ADAPTER_ENV: staging + NODE_ENV: production + depends_on: + redis: + condition: service_healthy + # SIGTERM chega ao processo do worker porque o entrypoint usa + # `node --import tsx` (sem processo filho). O handler espera o tick corrente + # para o `finally` liberar o lock. 30s cobrem um job médio. + stop_grace_period: 30s + healthcheck: + test: ["CMD", "/app/docker/entrypoint.sh", "worker-health"] + interval: 30s + timeout: 5s + start_period: 20s + retries: 3 + logging: *logging + networks: [adaptador-hml] + +networks: + adaptador-hml: + name: adaptador-planilhas-hml + +volumes: + redis-data: + name: adaptador-planilhas-hml-redis diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..063e7cf --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,111 @@ +# Reprodução local da topologia de homologação: mesma imagem, dois processos, +# Postgres e Redis de verdade. Serve para validar o artefato antes de subir ao +# servidor. Aqui o ambiente é `development`: não há Supabase local, então o +# armazenamento é um volume privado e a sessão usa o login local sem senha. +# +# docker compose up --build +# docker compose exec web /app/docker/entrypoint.sh migrate +# +# Nenhuma porta de serviço do ecossistema é usada: tudo em 127.0.0.1 e em +# portas altas, com nomes próprios de projeto, rede e volume. + +name: adaptador-planilhas-local + +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: adaptador + POSTGRES_PASSWORD: adaptador_local + POSTGRES_DB: adaptador + ports: + - "127.0.0.1:55432:5432" + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U adaptador -d adaptador"] + interval: 5s + timeout: 3s + retries: 20 + networks: [adaptador] + + redis: + image: redis:7-alpine + command: ["redis-server", "--save", "", "--appendonly", "no"] + ports: + - "127.0.0.1:56379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 20 + networks: [adaptador] + + web: + # Com ADAPTER_IMAGE definido, usa a imagem já construída (o CI faz isso). + # Sem ela, `up --build` constrói e marca com este nome. + image: ${ADAPTER_IMAGE:-adaptador-planilhas:local} + build: + context: . + target: runner + command: ["web"] + environment: &app-env + ADAPTER_ENV: development + ADAPTER_NAMESPACE: adaptador-planilhas + ADAPTER_JOB_MODE: worker + ADAPTER_APPLY_IDENTITY_SHIM: "1" + ADAPTER_DEV_AUTH: "1" + ADAPTER_DEV_AUTH_SECRET: segredo-apenas-local + DATABASE_URL: postgres://adaptador:adaptador_local@postgres:5432/adaptador + REDIS_URL: redis://redis:6379 + SPREADSHEET_TEMP_TTL_SECONDS: "3600" + ELO_PRO_APP_URL: http://localhost:3000 + PORT: "3000" + HOSTNAME: 0.0.0.0 + ports: + - "127.0.0.1:3000:3000" + volumes: + - app-storage:/app/.data + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + networks: [adaptador] + + worker: + image: ${ADAPTER_IMAGE:-adaptador-planilhas:local} + build: + context: . + target: runner + command: ["worker"] + environment: *app-env + volumes: + - app-storage:/app/.data + # O worker grava heartbeat ao fim de cada tick; o healthcheck detecta + # processo vivo mas travado, que um check de PID não pega. + healthcheck: + test: ["CMD", "/app/docker/entrypoint.sh", "worker-health"] + interval: 30s + timeout: 5s + start_period: 15s + retries: 3 + stop_grace_period: 30s + depends_on: + web: + condition: service_started + postgres: + condition: service_healthy + redis: + condition: service_healthy + networks: [adaptador] + +networks: + adaptador: + name: adaptador-planilhas-local + +volumes: + postgres-data: + name: adaptador-planilhas-local-postgres + app-storage: + name: adaptador-planilhas-local-storage diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..212434e --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,47 @@ +#!/bin/sh +# Entrada única da imagem. Mantém os comandos curtos e garante que todos os +# scripts operacionais rodem no MESMO processo do contêiner. +# +# Por que `node --import tsx` e não o CLI do tsx: o CLI cria um processo filho, +# e o SIGTERM do `docker stop` pararia no pai. O worker precisa receber o sinal +# para esperar o tick corrente e liberar o lock no `finally`. +set -eu + +TSX="--import=tsx" + +usage() { + cat <<'EOF' +uso: entrypoint.sh [args] + + web servidor Next (saída standalone) + worker processo de fila + migrate [--dry-run] + seed-staging + flag on|off + cleanup + verify-config + check-bucket + smoke + worker-health confere o heartbeat do worker (healthcheck do contêiner) + exec qualquer outro comando +EOF +} + +cmd="${1:-web}" +[ $# -gt 0 ] && shift || true + +case "$cmd" in + web) exec node server.js ;; + worker) exec node $TSX scripts/worker.ts ;; + migrate) exec node $TSX scripts/migrate.ts "$@" ;; + seed-staging) exec node $TSX scripts/seed-staging.ts "$@" ;; + flag) exec node $TSX scripts/set-flag.ts "$@" ;; + cleanup) exec node $TSX scripts/cleanup-temp.ts "$@" ;; + verify-config) exec node $TSX scripts/verify-config.ts ;; + check-bucket) exec node $TSX scripts/check-bucket.ts ;; + smoke) exec node $TSX scripts/smoke-staging.ts ;; + worker-health) exec node docker/worker-health.mjs ;; + exec) exec "$@" ;; + -h|--help|help) usage ;; + *) echo "comando desconhecido: $cmd" >&2; usage >&2; exit 2 ;; +esac diff --git a/docker/worker-health.mjs b/docker/worker-health.mjs new file mode 100644 index 0000000..f03c0b0 --- /dev/null +++ b/docker/worker-health.mjs @@ -0,0 +1,23 @@ +// Healthcheck do contêiner do worker. O worker grava o heartbeat ao fim de cada +// tick; se o arquivo envelhecer além da janela, o processo está vivo mas travado, +// que é o caso que um healthcheck de PID não detecta. +import { stat } from "node:fs/promises"; + +const file = process.env.ADAPTER_WORKER_HEARTBEAT ?? "/tmp/adaptador-worker-heartbeat"; +const tick = Number(process.env.ADAPTER_WORKER_TICK_MS ?? 2000); +// Tolera dez ticks ou 60s, o que for maior: um job grande ocupa o tick inteiro. +const maxAgeMs = Math.max(tick * 10, 60_000); + +try { + const info = await stat(file); + const age = Date.now() - info.mtimeMs; + if (age > maxAgeMs) { + console.error(`heartbeat com ${Math.round(age / 1000)}s, limite ${Math.round(maxAgeMs / 1000)}s`); + process.exit(1); + } + console.log(`heartbeat com ${Math.round(age / 1000)}s`); + process.exit(0); +} catch { + console.error(`heartbeat ausente em ${file}`); + process.exit(1); +}