# 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 ``` 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=`. 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 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 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 -- 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//:staging-`. 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//' --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 off # 4. Trocar para a imagem anterior. Só web e worker; o Redis fica de pé. export ADAPTER_IMAGE=ghcr.io//:staging- 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 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-`) 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 -- 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.