Havia dois runbooks sobrepostos, escritos para o piloto, e nenhum documento do ambiente de homologação. - `20-homologacao-runbook.md` consolida os dois e cobre DNS, TLS, Nginx, Supabase de homologação, banco com a decisão do shim, bucket privado, containers, worker, observabilidade, flag, rollback, diagnóstico e smoke. Os dois runbooks antigos ficam no repositório marcados como histórico, preservando o registro das decisões. - Rollback de imagem com comandos: identificar o que está no ar, listar tags, desligar a flag antes de trocar, subir só web e worker com `--no-deps`, conferir saúde, religar a flag. As migrations de segurança — RLS e auditoria append-only — estão declaradas sem caminho de volta: desfazê-las afrouxa o isolamento entre workspaces, então um rollback que precise disso está errado por definição. - `21-checklist-validacao.md` separa o que foi executado do que depende de infraestrutura, com a evidência de cada item e a tabela dos quatro defeitos encontrados. Nada está marcado PASS por inspeção de código. - ADR 0007 registra o ambiente lógico e a autenticação de homologação, com as alternativas descartadas e o motivo. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
19 KiB
Runbook de homologação — hml-ada.elopro.app
Runbook único do Adaptador de Planilhas em homologação. Substitui 07-deployment-runbook.md e 16-pilot-deployment-runbook.md, que ficam no repositório como histórico e não devem ser seguidos.
| Homologação | hml-ada.elopro.app |
| Produção futura | ada.elopro.app |
| Servidor | compartilhado com os outros aplicativos do ecossistema |
| Projeto do compose | adaptador-planilhas-hml |
| Rede Docker | adaptador-planilhas-hml |
| Volume | adaptador-planilhas-hml-redis |
| Portas publicadas | só em 127.0.0.1: web 3110, Redis 56380 |
| Namespace de chaves | adaptador-planilhas:staging |
Princípios que o procedimento respeita
- Nenhum serviço de outro aplicativo é criado, alterado ou reiniciado. O compose tem nome de projeto, rede e volume próprios, e o deploy usa
up -d --no-deps web worker. - DNS, serviços e banco de produção não são tocados. Homologação tem subdomínio, banco, bucket, Redis, projeto Supabase e projeto/ambiente Sentry próprios.
- Não há autenticação de desenvolvimento em homologação.
ADAPTER_DEV_AUTH=1faz o processo recusar o start fora dedevelopment. - Nenhuma credencial administrativa é compartilhada entre aplicações ou entre ambientes.
- RLS e as validações multi-tenant não são afrouxadas em nenhum passo.
- Migrations destrutivas não rodam automaticamente.
npm run migrateé um job separado, com aprovação manual, precedido denpm run migrate:check. - Produção não é liberada sem smoke real e ensaio de rollback registrados em
21-checklist-validacao.md.
1. DNS
Registro novo, sem mexer em nada existente:
hml-ada.elopro.app. A <IP do servidor compartilhado>
Se o ecossistema usa CNAME para um host único, siga o mesmo padrão dos outros aplicativos. Não altere o registro de ada.elopro.app nem o apex elopro.app.
Certificado TLS: inclua apenas hml-ada.elopro.app na emissão. Com Certbot e Nginx:
sudo certbot --nginx -d hml-ada.elopro.app
Isso cria um bloco novo. Não use --expand em um certificado que atende aplicativos existentes: uma renovação com falha derrubaria o TLS deles.
2. Nginx
Arquivo novo em /etc/nginx/sites-available/hml-ada.elopro.app, sem editar nenhum server existente:
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name hml-ada.elopro.app;
ssl_certificate /etc/letsencrypt/live/hml-ada.elopro.app/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/hml-ada.elopro.app/privkey.pem;
# Upload de modelo e de fonte: o limite do produto é 10 MB por arquivo.
# A margem cobre o envelope multipart.
client_max_body_size 12m;
# Processamento assíncrono responde rápido, mas a geração de PDF e XLSX
# do download é síncrona. O teto do job é SPREADSHEET_JOB_TIMEOUT_MS.
proxy_read_timeout 180s;
proxy_send_timeout 180s;
location / {
proxy_pass http://127.0.0.1:3110;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# Sondas internas: o monitoramento usa estas duas rotas.
location = /api/health/live { proxy_pass http://127.0.0.1:3110; access_log off; }
location = /api/health/ready { proxy_pass http://127.0.0.1:3110; }
}
server {
listen 80;
listen [::]:80;
server_name hml-ada.elopro.app;
return 301 https://$host$request_uri;
}
X-Forwarded-For é obrigatório: o rate limiting da tela de login identifica o chamador por ele.
Os cabeçalhos de segurança (CSP, HSTS, X-Frame-Options, Referrer-Policy, Permissions-Policy) vêm da aplicação, em next.config.ts. Não duplique no Nginx: add_header no servidor substituiria o conjunto da aplicação.
sudo ln -s /etc/nginx/sites-available/hml-ada.elopro.app /etc/nginx/sites-enabled/
sudo nginx -t # precisa passar antes de recarregar
sudo systemctl reload nginx # reload, nunca restart: não derruba os outros sites
3. Identidade (Supabase Auth de homologação)
O Adaptador não tem tabela de usuário, senha, cadastro ou sessão própria. Em homologação a identidade é um projeto Supabase separado, só de homologação.
- Crie o projeto Supabase de homologação. Anote URL, anon key e service role.
- A service role de homologação não é a de produção e não é compartilhada com nenhum outro aplicativo.
- Crie as contas de teste no painel do Supabase (Authentication → Users). Nenhuma senha passa pelo repositório, pelo seed ou por este runbook.
- Anote o UUID de cada conta criada: é o que liga a identidade ao workspace.
- Em
.env.staging, deixeADAPTER_STAGING_AUTH=1. Isso habilita/api/auth/staging/session, que fazsignInWithPasswordcontra esse projeto. A sessão é emitida e validada pelo Supabase. - Quando o SSO do Elo Pro existir, troque para
ADAPTER_STAGING_AUTH=0eELO_PRO_LOGIN_URL=<login central>. Nada mais muda: o contrato está emsrc/server/auth/identity-provider.ts.
Workspace ativo
O workspace não vem de cookie como prova. O cookie elo_pro_workspace_id é preferência; toda requisição relê as associações reais em workspace_members sob RLS (src/server/auth/workspace.ts):
- nenhuma associação → a pessoa vê "nenhum workspace";
- uma associação → escolha automática;
- várias → tela
/selecionar-workspace, ePOST /api/auth/workspacesó grava o cookie depois de confirmar a associação.
Seed de homologação
cp config/staging-seed.example.json config/staging-seed.json
# preencha users[].id com os UUID do Supabase Auth e escolha os UUID de workspace
npm run seed:staging
Idempotente: rodar de novo produz o mesmo estado. Não cria conta nem senha, só liga identidades a workspaces e papéis. A feature flag nasce desligada mesmo aqui.
4. Banco de homologação
# 1. Veja o que vai rodar, antes de rodar.
ADAPTER_ENV=staging DATABASE_URL=... ADAPTER_APPLY_IDENTITY_SHIM=? npm run migrate:check
A decisão do shim é explícita e obrigatória: sem ADAPTER_APPLY_IDENTITY_SHIM o start é recusado.
| Situação do banco | Valor | Efeito |
|---|---|---|
Banco novo, sem profiles/workspaces/workspace_members |
1 |
0001_platform_shim.sql cria o shim e o papel app_user |
| Banco já com o schema do Elo Pro | 0 |
0001 é pulado; só 0002, 0003 e 0004 rodam |
Com 0, antes de migrar: confirme que existem public.profiles(id, display_name, is_superadmin), public.workspaces(id, name) e public.workspace_members(workspace_id, user_id, role), e que o papel app_user existe sem BYPASSRLS. Se os nomes ou as colunas diferirem, adapte src/server/auth/identity-port.ts antes — é o único arquivo que precisa mudar.
# 2. Aplicar (job com aprovação manual no workflow).
npm run migrate
Nenhum processo aplica migration sozinho: o worker recusa iniciar com migration pendente e a web não migra em ambiente implantado.
5. Bucket privado
# Crie o bucket no painel do Supabase de homologação, com visibilidade privada.
# Nome sugerido: adaptador-planilhas-hml
npm run check:bucket
check:bucket falha se o bucket não existir ou for público. A aplicação também recusa o valor literal public em SUPABASE_STORAGE_BUCKET. Nenhuma URL pública é gerada: o download passa por /api/spreadsheet-adapter/jobs/[id]/download, autenticado e sob RLS.
6. Containers
A imagem tem um entrypoint com subcomandos. Use sempre o subcomando, nunca node direto: ele garante node --import tsx, que é o que faz o SIGTERM do docker stop chegar ao processo do worker. O CLI do tsx cria um processo filho e o sinal pararia nele.
cd <HML_DEPLOY_PATH>
cp .env.staging.example .env.staging
# preencha todos os campos e confira
export C="docker compose -f docker-compose.staging.yml --env-file .env.staging"
$C run --rm web verify-config
$C up -d redis
$C up -d --no-deps web worker
$C ps
| Subcomando | O que faz |
|---|---|
web |
servidor Next (saída standalone) |
worker |
processo de fila |
migrate [--dry-run] |
migrations |
seed-staging |
associa identidades do Supabase a workspaces |
flag <workspace_id> on off |
feature flag do módulo |
cleanup |
expira temporários fora do ciclo do worker |
verify-config |
confere a configuração sem tocar em recurso externo |
check-bucket |
confere que o bucket existe e é privado |
smoke |
smoke do ambiente |
worker-health |
confere o heartbeat do worker |
--no-deps e o --project-name embutido (adaptador-planilhas-hml) garantem que nenhum stack de outro aplicativo é tocado. Nunca rode docker compose down sem -f docker-compose.staging.yml, e nunca docker system prune neste servidor.
Web e worker saem da mesma imagem: mesmo código, mesmas dependências. A diferença é só o subcomando. O node_modules de produção e o traçado pelo standalone vêm do mesmo lockfile e não divergem em nenhuma versão; a imagem sobrepõe as duas árvores nessa ordem, com a traçada por último, que é a que o server.js espera.
O Redis é um contêiner exclusivo deste aplicativo, com senha, volume e porta próprios. Mesmo assim as chaves são prefixadas com adaptador-planilhas:staging, então um Redis compartilhado por engano não misturaria homologação com produção.
7. Worker
$C logs -f worker
$C exec worker /app/docker/entrypoint.sh worker-health
O start imprime [worker] pronto. intervalo=…ms lote=… carencia=…ms alvo=postgres heartbeat=….
ADAPTER_JOB_MODE=workeré obrigatório.inlinefaz o start ser recusado.- Migration pendente faz o worker sair com código 1, com a lista no log.
SIGTERMespera o tick corrente (atéADAPTER_WORKER_GRACE_MS, padrão 20 s, dentro dostop_grace_periodde 30 s) para ofinallyliberar o lock. Sem isso o job ficaria emprocessingaté o lock vencer por TTL. O log de uma parada limpa trazSIGTERM recebidoe[worker] encerrado; se faltarem, o contêiner levou SIGKILL e o lock vai esperar o TTL de 120 s.- Um job que estava apenas na fila, e não começou, permanece
queued: nada pela metade. O worker seguinte o conclui. - O worker grava um heartbeat ao fim de cada tick (
ADAPTER_WORKER_HEARTBEAT, padrão/tmp/adaptador-worker-heartbeat). O healthcheck do contêiner usa esse arquivo, porque um processo vivo mas travado passa em qualquer verificação de PID. - Cada tick também expira temporários (
cleanupExpired) e recupera jobs órfãos (recoverStaleJobs). Não há cron separado. - O log traz nome e código do erro. Nunca conteúdo de célula.
8. Observabilidade
| Item | Onde |
|---|---|
| Ambiente no Sentry | SENTRY_ENVIRONMENT=staging; sem a variável, cai em ADAPTER_ENV |
| Release | SENTRY_RELEASE = SHA do commit, injetado como build-arg da imagem |
| Sourcemap | withSentryConfig só atua com SENTRY_ORG, SENTRY_PROJECT e SENTRY_AUTH_TOKEN no build |
| Erros de rota e render | onRequestError em src/instrumentation.ts |
| Sanitização | scrubSentryEvent descarta texto livre, cookie, header de autorização e qualquer campo de célula |
| Liveness | GET /api/health/live — não toca dependência |
| Readiness | GET /api/health/ready — banco, migrations pendentes, Redis (PING), bucket (listagem), configuração e modo de job |
| Auditoria | public.audit_log, append-only para app_user, sem conteúdo de célula |
Teste a sanitização provocando um erro controlado e confirmando no Sentry que o evento não traz célula, cookie nem nome de pessoa.
9. Liberação da feature flag
Último passo, depois do smoke:
npm run flag -- <workspace_id> on
O script recusa um workspace que não existe e registra o evento em audit_log. A flag nasce desligada, inclusive em homologação.
10. Rollback
Aplicação: rollback de imagem (reversível, é o caminho normal)
O artefato é imutável e marcado com o SHA do commit: ghcr.io/<org>/<repo>:staging-<sha>. Voltar é apontar o compose para a tag anterior. Nada de banco, bucket ou auditoria é tocado.
export C="docker compose -f docker-compose.staging.yml --env-file .env.staging"
# 1. Qual imagem está no ar agora. Anote: é o ponto de retorno se o rollback falhar.
$C ps --format '{{.Service}} {{.Image}}'
docker inspect "$($C ps -q web)" --format '{{.Config.Image}}'
# 2. Quais tags existem para voltar (mais recentes primeiro).
docker image ls 'ghcr.io/<org>/<repo>' --format '{{.Tag}} {{.CreatedAt}}' | sort -k2 -r | head
# 3. Desligar o módulo para os usuários. Efeito imediato, sem deploy.
# Faça isto ANTES de trocar a imagem: encerra o incidente de produto em segundos.
$C run --rm web flag <workspace_id> off
# 4. Trocar para a imagem anterior. Só web e worker; o Redis fica de pé.
export ADAPTER_IMAGE=ghcr.io/<org>/<repo>:staging-<sha-anterior>
docker pull "$ADAPTER_IMAGE"
$C up -d --no-deps web worker
# 5. Conferir que voltou e está saudável.
docker inspect "$($C ps -q web)" --format '{{.Config.Image}}'
curl -fsS https://hml-ada.elopro.app/api/health/live
curl -fsS https://hml-ada.elopro.app/api/health/ready
$C exec worker /app/docker/entrypoint.sh worker-health
# 6. Religar a flag só depois de confirmar o ambiente.
$C run --rm web flag <workspace_id> on
Para parar apenas o processamento, sem derrubar a interface:
$C stop -t 30 worker # respeita a carência: o lock é liberado
Jobs queued param de avançar e são retomados quando o worker volta. Jobs processing sem lock viram INTERRUPTED na próxima varredura e podem ser repetidos pelo usuário. Nenhuma saída já gerada é apagada: ela vence pelo TTL.
Fixe a tag no .env.staging (ADAPTER_IMAGE=...:staging-<sha>) para que um up -d acidental não traga de volta a imagem com defeito.
Migrations não voltam no rollback de imagem. As migrations do Adaptador são aditivas: a imagem anterior convive com o schema novo. Se a imagem anterior não conviver, o problema é de compatibilidade de schema e vira operação planejada, não rollback às pressas.
Schema (não automatizado, de propósito)
Não existe rollback automático de schema e não deve existir.
| Migration | Caminho de volta |
|---|---|
0002_spreadsheet_adapter.sql |
migrations/down/0002_spreadsheet_adapter_down.sql, aplicado à mão, com backup antes |
0003_rls.sql |
sem down. Remover policy é afrouxar isolamento: não se desfaz, corrige-se para frente |
0004_audit_append_only.sql |
sem down. A trava de auditoria não se remove em rollback |
0001_platform_shim.sql |
sem down. O shim só existe em banco que não tem o schema do Elo Pro |
Regra: desligar a flag resolve o incidente de produto. Reverter imagem resolve o incidente de código. Mexer em schema é operação planejada com backup, nunca parte de um rollback às pressas.
Migrations de segurança são preservadas em qualquer rollback. 0003_rls.sql (RLS e FORCE RLS) e 0004_audit_append_only.sql (auditoria append-only) não têm e não terão caminho de volta: desfazê-las afrouxa isolamento entre workspaces e abre a auditoria para alteração pelo papel da aplicação. Um rollback que precise disso está errado por definição.
Não apague audit_log, modelos, banco ou arquivos em nenhum rollback.
11. Diagnóstico
| Sintoma | Causa provável | Verificação |
|---|---|---|
| Container sai no start com código 1 | configuração recusada | docker compose logs web traz a lista de variáveis |
/api/health/live não responde |
processo caiu | configuração é a primeira hipótese: veja o log do start |
ready com config: missing |
variável ausente ou inválida | configIssues na resposta nomeia cada uma |
ready com redis: down |
senha, host ou contêiner | docker compose ps redis e o PING |
ready com storage: down |
bucket, nome ou service role | npm run check:bucket |
ready com migrations: missing |
migration pendente | npm run migrate:check |
ready com job_mode: missing |
ADAPTER_JOB_MODE=inline |
corrija e suba de novo |
| Login cai em branco | ADAPTER_STAGING_AUTH desligado e sem ELO_PRO_LOGIN_URL |
/login explica qual falta |
| Fica pedindo workspace | identidade sem associação | npm run seed:staging ou incluir em workspace_members |
Job preso em processing |
worker parado ou lock vencido | log do worker; a varredura marca INTERRUPTED |
| 429 nas chamadas | rate limiting | Retry-After na resposta; tetos em src/server/rate-limit.ts |
12. Ordem completa, do zero
- DNS de
hml-ada.elopro.app. - Certificado TLS só desse host.
servernovo no Nginx,nginx -t,systemctl reload nginx.- Projeto Supabase de homologação; contas de teste no painel.
- Bucket privado;
npm run check:bucket. .env.stagingpreenchido;npm run verify:config.npm run migrate:checke depoisnpm run migrate(aprovação manual).config/staging-seed.jsonenpm run seed:staging.up -d redis, depoisup -d --no-deps web worker./api/health/liverespondelive;/api/health/readyrespondeready.- Smoke, nas duas fases (ver seção 13).
- Roteiro manual de
21-checklist-validacao.md: login real, isolamento, processamento assíncrono, download XLSX/CSV/PDF, erro no Sentry. npm run flag -- <workspace_id> on.- Ensaio de rollback, com evidência.
- Só então discutir produção.
13. Smoke de homologação
Fase 1 roda sem nenhuma credencial: saúde, recusa sem sessão, cabeçalhos de segurança e ausência de dev auth.
ADAPTER_SMOKE_URL=https://hml-ada.elopro.app npm run smoke:staging
Fase 2 só roda quando a operação fornece uma conta de teste criada no painel do Supabase de homologação. Nenhuma credencial fica no repositório, e o script não inventa conta: sem estas variáveis a fase 2 é reportada como PENDENTE, nunca como aprovada.
| Variável | Obrigatória | O que é |
|---|---|---|
ADAPTER_SMOKE_URL |
sim | https://hml-ada.elopro.app |
ADAPTER_SMOKE_EMAIL |
fase 2 | e-mail de uma conta de teste do Supabase de homologação |
ADAPTER_SMOKE_PASSWORD |
fase 2 | senha dessa conta |
ADAPTER_SMOKE_WORKSPACE |
opcional | UUID do workspace que deve aparecer associado |
ADAPTER_SMOKE_URL=https://hml-ada.elopro.app ADAPTER_SMOKE_EMAIL=... ADAPTER_SMOKE_PASSWORD=... ADAPTER_SMOKE_WORKSPACE=... npm run smoke:staging
A fase 2 confere: login real emitindo sessão, associações de workspace resolvidas no servidor, seleção de workspace aceita e leitura autenticada do módulo. Com a feature flag ainda desligada, FEATURE_DISABLED é resposta correta e passa.
Continua fora do smoke, no roteiro manual, porque exige arquivo e flag ligada:
- envio de modelo e de fonte pela interface;
- processamento assíncrono com o worker e download XLSX, CSV e PDF;
- troca de workspace e sessão expirada no navegador;
- erro controlado no Sentry, confirmado sem conteúdo de célula;
- ensaio de rollback de imagem.