Runbook de produção — Render (fase A)
Guia de subida do Render em produção no MyPanel. Nada aqui foi executado: este
documento é o roteiro, o deploy é decisão do dono e o autoDeploy nasce desligado
(D12). Projeto do painel: workspace_nommand (947JSvgPNhtoIM97ko6ie), ao lado dos
outros 9 serviços.
Fonte do desenho: docs/desenho/desenho.md §7 (topologia), §6.2 (env), §8 (migração),
§10 (corte). Onde este runbook e o desenho divergirem, vale o desenho e a divergência
vira correção de um dos dois.
1. Topologia
| Serviço (novo) | Origem | Porta | Memória | Volume | Domínio |
|---|---|---|---|---|---|
| `render-backend-ws` | `PedroPaduelo/render`, branch `main`, buildPath `/backend`, `Dockerfile` | 4000 (não publicada) | 1024 MB | `render-prod-data` → `/data` | `render-v0-api.mp.serendiped.com` → 4000, SSL |
| `render-frontend-ws` | idem, buildPath `/frontend` | 8080 (não publicada) | 256 MB | — | `render-v0.mp.serendiped.com` → 8080, SSL |
| `render-shot-ws` (opcional) | idem, buildPath `/shot` | 4500 | 1024 MB | — | **sem domínio** (só o backend fala) |
- autoDeploy desligado nos três (D12). Merge na
mainnão publica; publicação é
deployments_trigger, decisão do dono.
- Nomes de container são globais no painel (
minipanel-<serviço>);render-backend-ws
não colide com o sandbox sb-render.
- Repositório privado: o deploy usa o token de *Sandboxes > Git access*.
- Banco: Postgres próprio da app (projeto
databases_prod), com
max_connections acima de 1000 e DB_CONNECTION_LIMIT=1000 na app (D11). O
Redis é opcional (só os contadores de rate limit; sem REDIS_URL a contagem é por
instância).
- DNS:
render-v0.mp.serendiped.comerender-v0-api.mp.serendiped.comapontando para
o painel, com SSL provisionado pelo painel.
2. Pré-requisitos (ação do dono, fora do MyPanel)
- Cliente OIDC novo na Conta Nommand, id
render, claimapps["render"]; redirect e
post-logout do §3 abaixo.
2. CONTA_PROVISIONING_SECRET novo e exclusivo desta app (é o que identifica o
app.contract.cancelled dela) — gerado e guardado fora do repositório.
3. Token de git com acesso a PedroPaduelo/render salvo no painel.
4. Postgres próprio criado, max_connections acima de 1000, credencial guardada fora do
repositório.
5. Entrada no AppSwitcher com slug render e papel apps["render"] liberado na empresa.
Sem isso a app não entra nem para a pessoa nem para o webhook.
3. Variáveis de ambiente (nomes, sem valores)
`render-backend-ws`
Segredos (nunca no repositório, nunca no log): DATABASE_URL, DIRECT_URL,
RENDER_AGENT_TOKEN, JWT_SECRET, MCP_SECRET_KEY, MCP_SECRET_KEY_PREVIOUS,
OIDC_CLIENT_SECRET, CONTA_PROVISIONING_SECRET, SHOT_TOKEN, REDIS_URL.
Configuração: NODE_ENV, PORT, HOST, DB_CONNECTION_LIMIT, AUTH_ENFORCED,
JWT_TTL, OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_REDIRECT_URI,
OIDC_POST_LOGOUT_REDIRECT_URI, APP_PUBLIC_URL, CORS_ORIGIN, FRONTEND_URL,
MCP_ALLOWED_ORIGINS, MCP_ALLOWED_HOSTS, TRUST_PROXY, RATE_LIMIT_MAX,
RATE_LIMIT_WINDOW_MS, RATE_LIMIT_IP_MAX, AUDIT_RETENTION_DAYS,
SHUTDOWN_TIMEOUT_MS, SHUTDOWN_DRAIN_DELAY_MS, SWAGGER_ENABLED, LOG_LEVEL,
PRISMA_LOG_QUERIES, RENDER_ASSETS_DIR, RENDER_REQUIRE_IF_MATCH,
RENDER_REVISION_KEEP, SHOT_SERVICE_URL, SHOT_SERVICE_ALLOWED_HOSTS,
SHOT_TIMEOUT_MS, CHECKPOINT_DISABLE.
Do desenho §7: MCP_ALLOWED_HOSTS recebe o host do domínio da API (o mcpHostGuard
casa request.host), CORS_ORIGIN e APP_PUBLIC_URL recebem o domínio do frontend,
TRUST_PROXY=1, OIDC_REDIRECT_URI é <domínio do frontend>/auth/callback e o
post-logout é o domínio do frontend.
`render-frontend-ws`
BACKEND_UPSTREAM (nome interno do serviço de backend, nunca como default no
Dockerfile), NODE_ENV, PORT, NOMAD_UI_TOKEN (só se o nomad-ui voltar a ser
privado).
`render-shot-ws`
SHOT_ALLOW_ORIGINS (o domínio do frontend), SHOT_TOKEN (o mesmo do backend),
NODE_ENV, PORT.
> Conferência automática: npm run env:example gera backend/.env.example a partir do
> env-schema e o gate env-example-check falha se os dois divergirem. A lista acima
> deve bater com o .env.example commitado.
4. Banco
- Criar o Postgres próprio em
databases_prodcommax_connectionsacima de 1000.
2. Conferir depois do boot: SHOW max_connections acima de 1000 e, no app,
SELECT * FROM pg_stat_activity com o pool do Prisma dentro do DB_CONNECTION_LIMIT.
3. O schema entra pelo prisma migrate deploy do docker-entrypoint.sh: migration
falhada aborta o boot (não sobe API com schema velho). A migration é aditiva e o
reaplicar é idempotente (provado no render-migration.test.ts).
Verificação: curl -fsS https://render-v0-api.mp.serendiped.com/health responde 200 com
o campo de schema saudável; npm run test:db:drift (em qualquer checkout) confere
migrações × schema.
5. Ordem de subida
| # | Passo | Como verificar |
|---|---|---|
| - | ----- | -------------- |
| 1 | **Banco**: criar o Postgres próprio e a credencial | `psql` conecta; `SHOW max_connections` > 1000 |
| 2 | **Backend** (`render-backend-ws`): env vars, deploy, `migrate deploy` no boot | log do boot mostra o `migrate deploy` OK e o `listen` na 4000; `/health` 200; `/metrics` 200; `GET /api/llms` 200 |
| 3 | **Frontend** (`render-frontend-ws`): env vars, deploy | `GET https://render-v0.mp.serendiped.com/` 200 e o shell carrega; `GET /api/health` pelo proxy do nginx 200 |
| 4 | **shot** (`render-shot-ws`) **por último**, e só se o preview for usado | o backend loga o health do shot; `SHOT_SERVICE_URL` responde; captura de preview funciona numa tela real |
Cada conferência registra o commit do build (o painel mostra) e o hash do bundle do
frontend: com autoDeploy desligado, um Dockerfile alterado sem commit faz o
deployments_trigger construir o commit antigo (desenho §7).
Detalhes de borda que a subida precisa conferir:
- nginx do frontend: resolver DNS fixo no
proxy_pass(lista fixa de nameserver já deu
502 em produção duas vezes), client_max_body_size 25m, CSP com `style-src
'unsafe-inline' e img-src/media-src com https:, data:, blob:`, e proxy de
/api, /auth/sso, /auth/callback e /mcp na mesma origem.
- Lockfile do frontend só muda com
npx npm@10.9.4 install --package-lock-only,
conferido com npx npm@10.9.4 ci --dry-run (lock fora de sincronia já quebrou deploy).
- Cookie de preview:
httpOnlycom desafio one-shot (D15) — nuncasessionStorage. - Smoke do
/mcp:POST https://render-v0-api.mp.serendiped.com/mcpcom uma chave
apk_* da empresa responde o envelope {ok,...}; sem credencial, 401.
6. Rollback
- Serviço:
deployments_rollbackpara o deployment anterior no painel (o backend
primeiro, depois o frontend). Conferir o commit do build e o hash do bundle voltando
aos anteriores.
2. Banco: as migrations da fase A são aditivas; rollback de código não exige
rollback de schema. Só a remoção de coluna (fase B) exigiria passo próprio.
3. Volume (regra nova, obrigatória): o render-prod-data não volta sozinho. Regras:
(a) antes do corte, backup do volume ou nota de que ele está vazio; (b) o rollback
descarta ou renomeia o volume novo, ou reverte o diretório de assets do AgentPack
ao estado pré-corte; (c) o script de migração tem de ser idempotente sobre o volume
(duas execuções, mesmo resultado de bytes), não só sobre as linhas.
4. Corte não concluído: reabrir a escrita no AgentPack (o AgentPack continua dono dos
assets e do catálogo até o corte). O rollback declarado "descartar o banco novo" não
cobre os bytes — ver §7 do desenho.
7. Pendências e ressalvas verificadas no fechamento da fase A
| Item | Estado | Onde |
|---|---|---|
| (a) `globalWriteReach` devolvia `undefined` para `design-system-id` (fail-open latente) | **corrigido** — `assertDesignSystemWriteAllowed` consulta a dona da linha no banco e responde 403 `GLOBAL_WRITE_NOT_ALLOWED` à chave restrita; teste `tests/auth-design-system-write.test.ts` prova o 403 | `backend/src/plugins/auth.ts` |
| (b) Casos de isolamento da borda R05–R08 sem teste | **fechados** — 4 testes novos em `tests/auth-edge.test.ts`, cada um derrubando a mutação correspondente (log em `.sbcache/wf-render-build/fechamento/logs/mutacoes.log`) | `backend/tests/auth-edge.test.ts` |
| (c) `tests/shutdown.test.ts` do AgentPack não portado (`shutdown.ts`/`server.ts` sem cobertura direta; R13 e R14 sobreviveram às mutações) | **pendente registrado** — portar na fase em que o P13 migrar a suíte | — |
| (d) `DB_CONNECTION_LIMIT` 1000 | **parcialmente fechado** — o teste existe (`tests/config-env.test.ts`, default 1000, inteiro positivo, vence o `connection_limit` da URL, e `withConnectionLimit`); o que nenhum teste cobre é o `max_connections` do **servidor**, que só a verificação do §4 confere | §4 deste runbook |
| (e) Testes que não provam o que dizem (R02, R15, R19, `/mcp`) | **pendente registrado** — R02 (`RENDER_AGENT_TOKEN` podendo repetir `MCP_SECRET_KEY`), R15 (header `X-Content-Type-Options`), R19 (`CMD` sem `exec` no entrypoint) e o caso do `/mcp` continuam sem teste que derrube a mutação | — |
| (f) `organization_id` em `audit_events` e no ator bootstrap | **feito pelo P1, com uma decisão**: a coluna existe, o `auditService` grava a empresa (do principal, do projeto escrito ou da escrita) e 19 testes cobrem; o **ator bootstrap continua sem empresa por decisão do P1** (chave de plataforma): fora de projeto a linha de auditoria fica sem empresa. O CHECK `<tabela>_one_owner` impede linha de catálogo sem empresa | `backend/prisma/schema.prisma`, `backend/src/services/audit.service.ts` |
| Piso de cobertura de `src/services` do P4 baixado 93 → 84 (medido 84,27%) | registrado no merge do P4 | `.sbcache/wf-render-build/P4/` |
| Dry-run de upgrade manda texto de `agentpack_task_decision_create` (tool que não existe na app) | pendente do P4, vem com o P7 (tela de decisão nova) | — |
| `isOwnedBy` (render-db.ts) e o guard `kind !== 'manifest'` sobreviveram a mutação no P4 | pendente do P4: teste `getAssetTx`/`deleteAssetTx` com escopo global de A sobre asset de B → `NOT_FOUND` | — |
| P5 (rotas REST) fora desta entrega — 2 gates falhando (`knip`, `coverage-floor`) | fora do escopo deste fechamento | `.sbcache/wf-render-build/P5/` |
8. Fase B — o que a fase B exige do AgentPack (nada feito agora)
| Item | O que é | Quando |
|---|---|---|
| **P-1** | Mover `toolError` de `tools/render/common.ts` para `mcp/tool-error.ts` (usado por `mcp/tool-call.ts` e `mcp/proxy-call.ts`, hoje reexportado pelo módulo do Render). Sem isso, apagar a pasta do Render derruba **toda** tool, inclusive as proxied | antes de tudo, sem mudança de comportamento |
| **P-2** | `organizationId` em `McpServer` (catálogo de MCP por empresa) + normalização do prefixo upstream em `proxiedWireName` | antes de cadastrar o MCP do Render |
| **D6** | Cadastrar o `/mcp` do Render no proxy do AgentPack com `toolPrefix: agentpack_render` — **só depois do P-2** (senão os 59 nomes viram `agentpack_render_agentpack_render_*`). Ressalvas aceitas: a tool proxied recebe escopo `write` e não `render:write`; a identidade do agente não atravessa; toda chamada proxied vira linha de auditoria de escrita; `X-AgentPack-Toolsets` e `X-AgentPack-Project` precisam atravessar por header fixo no vínculo | no corte |
| **D7** | Skills `agentpack-render-*` ficam no AgentPack na fase 1. **Ressalva**: se a ponte for desativada e o registro do Render sair do AgentPack, as 7 skills passam a citar tools inexistentes e o `check:skills` quebra — reescrever ou aposentar nesse momento | no corte |
| **D13** | Migração dos dados de produção: **só o script e este runbook** (`backend/scripts/migrate-render-from-agentpack.ts`, `--dry-run` obrigatório, `--apply` explícito, `--confirm-production` para sair do dry-run, log com contagens e nunca com conteúdo). **Não executar contra produção** sem o dono | fase B |
| **Corte** | Remover `RENDER_TOOLS` do registro do AgentPack (depois do P-2), normalizar o prefixo e cadastrar o MCP do Render. Os 4 pontos que quebram o AgentPack no dia da remoção e viram preparo antes: `toolError` em `tools/render/common.ts` (P-1), `appBaseUrl` em `lib/render-view-url.ts` (usado por `task-decision.service.ts`), as 3 tools `project_*` morando no registro do Render e as 2 chaves de `rate-limit.ts` cujo boot falha se a rota sumir | fase B |
| Complemento no app novo | `server.json` publicando o `/mcp` da app, docs `llms-full.txt`/`docs/mcp/tools.md` próprios (P13), e a tela de decisão de upgrade (D2, P7) — o path humano do portão continua REST sem `decisionId` | fase A restante / B |