Runbook único de homologação, checklist de evidências e ADR do ambiente
ci / lint, typecheck, testes e build (push) Has been cancelled
ci / Postgres e Redis reais (push) Has been cancelled
ci / imagem Docker Linux (push) Has been cancelled

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>
This commit is contained in:
camilafagundesandClaude Opus 5 committed 2026-10-15 20:40:08 -03:00
1 parent a1ceef6c5f
commit 5b11496cd9
6 files changed
+644 -7

No files matched your search

@@ -0,0 +1,368 @@
# 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
1. 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`.
2. 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.
3. Não há autenticação de desenvolvimento em homologação. `ADAPTER_DEV_AUTH=1` faz o processo recusar o start fora de `development`.
4. Nenhuma credencial administrativa é compartilhada entre aplicações ou entre ambientes.
5. RLS e as validações multi-tenant não são afrouxadas em nenhum passo.
6. Migrations destrutivas não rodam automaticamente. `npm run migrate` é um job separado, com aprovação manual, precedido de `npm run migrate:check`.
7. 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:
```bash
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:
```nginx
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.
```bash
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.
1. Crie o projeto Supabase de homologação. Anote URL, anon key e service role.
2. A service role de homologação não é a de produção e não é compartilhada com nenhum outro aplicativo.
3. Crie as contas de teste no painel do Supabase (Authentication → Users). Nenhuma senha passa pelo repositório, pelo seed ou por este runbook.
4. Anote o UUID de cada conta criada: é o que liga a identidade ao workspace.
5. Em `.env.staging`, deixe `ADAPTER_STAGING_AUTH=1`. Isso habilita `/api/auth/staging/session`, que faz `signInWithPassword` contra esse projeto. A sessão é emitida e validada pelo Supabase.
6. Quando o SSO do Elo Pro existir, troque para `ADAPTER_STAGING_AUTH=0` e `ELO_PRO_LOGIN_URL=<login central>`. Nada mais muda: o contrato está em `src/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`, e `POST /api/auth/workspace` só grava o cookie depois de confirmar a associação.
### Seed de homologação
```bash
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
```bash
# 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.
```bash
# 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
```bash
# 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.
```bash
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
```bash
$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. `inline` faz o start ser recusado.
- Migration pendente faz o worker sair com código 1, com a lista no log.
- `SIGTERM` espera o tick corrente (até `ADAPTER_WORKER_GRACE_MS`, padrão 20 s, dentro do `stop_grace_period` de 30 s) para o `finally` liberar o lock. Sem isso o job ficaria em `processing` até o lock vencer por TTL. O log de uma parada limpa traz `SIGTERM recebido` e `[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:
```bash
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.
```bash
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:
```bash
$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
1. DNS de `hml-ada.elopro.app`.
2. Certificado TLS só desse host.
3. `server` novo no Nginx, `nginx -t`, `systemctl reload nginx`.
4. Projeto Supabase de homologação; contas de teste no painel.
5. Bucket privado; `npm run check:bucket`.
6. `.env.staging` preenchido; `npm run verify:config`.
7. `npm run migrate:check` e depois `npm run migrate` (aprovação manual).
8. `config/staging-seed.json` e `npm run seed:staging`.
9. `up -d redis`, depois `up -d --no-deps web worker`.
10. `/api/health/live` responde `live`; `/api/health/ready` responde `ready`.
11. Smoke, nas duas fases (ver seção 13).
12. Roteiro manual de `21-checklist-validacao.md`: login real, isolamento, processamento assíncrono, download XLSX/CSV/PDF, erro no Sentry.
13. `npm run flag -- <workspace_id> on`.
14. Ensaio de rollback, com evidência.
15. 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.
```bash
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 |
```bash
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.