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)OrigemPortaMemóriaVolumeDomí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`45001024 MB—**sem domínio** (só o backend fala)

deployments_trigger, decisão do dono.

não colide com o sandbox sb-render.

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).

o painel, com SSL provisionado pelo painel.

2. Pré-requisitos (ação do dono, fora do MyPanel)

> Executado em 2026-10-14 — os cinco itens estão feitos; o que depende de deploy/restart

> está marcado. Evidências e hashes: .sbcache/wf-render-prod/conta-2026-10-14.log.

no banco de produção da Conta (conta-prod-postgres, banco conta) com as mesmas

linhas que a própria Conta escreve (apps), não com um mecanismo novo. Prova ao vivo:

GET https://conta.mp.serendiped.com/api/oidc/authorize?client_id=render&… responde 302

para /login (cliente conhecido e redirect aceito); um client_id inventado cai em

/authorize/error. clientType=CONFIDENTIAL, firstParty, RS256, sortOrder=40.

2. CONTA_PROVISIONING_SECRET novo e exclusivo — FEITO nos dois lados. Conta:

CONTA_PROVISIONING_SECRET_RENDER em conta-backend-ws (é o nome que

provisioningSecretEnvName('render') lê). Render: CONTA_PROVISIONING_SECRET em

render-backend-ws. Conferido por hash: os três lugares (arquivo 0600, painel dos dois

serviços) batem. Pendência de runtime: esse valor só entra no processo do

conta-backend-ws na próxima recriação do container (deploy). Um

services_restart não aplica env nova — verificado em 2026-10-14 (o container

reiniciou e o env continuou sem a chave). Precisa estar no env **antes do primeiro

webhook**, senão a entrega vira SKIPPED com sem segredo (CONTA_PROVISIONING_SECRET_RENDER).

3. Token de git com acesso a PedroPaduelo/render salvo no painel — já estava (deploy usa

*Sandboxes > Git access*).

4. Postgres próprio criado, max_connections acima de 1000, credencial guardada fora do

repositório — feito (§4).

5. Entrada no AppSwitcher com slug render e papel apps["render"] — FEITO. A linha

apps + a contratação org_apps nas duas empresas (fiscaliza e bebis, mesmo padrão

dos 3 apps vivos) é o que alimenta o launcher (/api/apps/launcher) e o

/api/oidc/topbar de todos os apps — não há código a mudar no LB/Motor/AgentPack.

O papel sai em apps["render"] pela regra de accessibleApps: OWNER/ADMIN abrem com

admin (implícito) e MEMBER com a liberação explícita (hoje as 6 filiações são

OWNER/ADMIN, então todo mundo já abre). Ícone: iconSvg na linha, servido em

GET /api/apps/render/icon (respondeu 200 com o SVG em 2026-10-14).

O que falta na Conta (fora do escopo desta tarefa)

ItemQuandoComo
`CONTA_PROVISIONING_SECRET_RENDER` no env **rodando** do `conta-backend-ws`antes do 1º webhook (resync)deploy/recriação do serviço — **não** `services_restart`
Resync do estado (empresa, membros, acesso) para o Renderdepois do deploy do RenderConta → Administração → Aplicativos → empresa → Render → *Reenviar estado* (`POST /api/platform/organizations/:orgId/apps/:appId/provisioning/resync`), senão o Render fica sem espelho de empresa/membros
Se algum dia a Conta passar a gerenciar o app pelo catálogo (`appCatalog.ts`)quando mexer no código da Contaadicionar `render` + `APP_FRONT_URL_RENDER` + glifo em `appIcons.ts`/`Brand.tsx`/design; o seed do boot atualiza nome/descrição/ícone e **não** mexe em URL nem segredo

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.

> Duas dessas ficam vazias por decisão, não por esquecimento (o check-env --production

> passa sem elas):

> - MCP_SECRET_KEY_PREVIOUS — é só da rotação ("previous"); num deploy novo não há

> chave anterior. Unset até a primeira rotação de MCP_SECRET_KEY.

> - REDIS_URL — opcional (só os contadores de rate limit). Não há Redis do Render;

> sem ela a contagem é por instância.

> OIDC_CLIENT_SECRET está preenchida desde 2026-10-14 — decisão tomada: cliente

> CONFIDENTIAL na Conta (igual aos outros 3 apps da Nomad). O valor cru vive no painel

> (render-backend-ws/OIDC_CLIENT_SECRET, mascarado na leitura) e numa cópia 0600 em

> .sbcache/wf-render-prod/secrets/oidc_client_secret; o hash SHA-256 dele é o

> apps.clientSecretHash da Conta. Os três batem (provado por hash). "Girar segredo" em

> Aplicativos → Render troca o hash da Conta e devolve um valor novo que precisa voltar

> para esta variável — sem isso o /token reprova (client_secret inválido).

> Preencher com um valor qualquer também quebra o login (fail-closed no /token).

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.

> ⚠️ Não existe CHECKPOINT_DISABLE. Uma versão anterior desta lista trazia essa

> chave; o env-schema (e o check-env do boot) rejeitaria. As chaves reais são 41 —

> contadas em backend/.env.example — e o gate env-example-check é o árbitro.

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). Valor usado em produção: BACKEND_UPSTREAM=minipanel-render-backend-ws:4000

— sem esquema (http://), porque o template do nginx faz

set $backend "http://${BACKEND_UPSTREAM}"; (mesmo padrão do

minipanel-conta-backend-ws:4100 da Conta). O nome do container é

minipanel-<nome-do-serviço>.

> ⚠️ Não publicável ainda. Em main (3c2aaed) frontend/ tem só 3 arquivos

> gerados — sem package.json e sem Dockerfile. O serviço pode ser criado

> (config pronta), mas o primeiro build falha até o P8 trazer o frontend.

> render-shot-ws também não existe em nenhuma branch (só entra se o preview for

> usado, e por último).

`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

> Executado em 2026-10-14 — render-prod-postgres (KcpL5jwOrrs9_Jir5Ah8J),

> postgres:16-alpine, volume render-prod-postgres-data → /var/lib/postgresql/data,

> 4096 MB, interna 5432, externa 25446, SHOW max_connections = 1200 (medido).

> Evidência: .sbcache/wf-render-prod/infra-producao-2026-10-14.log.

Como aplicar o max_connections: o painel ignora o command de

sourceConfig — aceita o campo, mas o container nasce com Cmd: ["postgres"]

(conferido em /proc/1/cmdline e docker inspect), então -c max_connections=…

não tem efeito. O caminho que funciona e persiste é ALTER SYSTEM, que grava

no postgresql.auto.conf dentro do volume e sobrevive a restart e recriação:

```bash

# com POSTGRES_PASSWORD do serviço em PGPASSWORD (nunca em argv)

psql "postgresql://render@217.216.81.188:25446/render" -c "ALTER SYSTEM SET max_connections = 1200;"

# reiniciar o serviço pelo painel; então:

psql "postgresql://render@217.216.81.188:25446/render" -tAc "SHOW max_connections;" # -> 1200

```

⚠️ psql recusa o parâmetro ?schema=public do Prisma

(invalid URI query parameter: "schema"): tire o sufixo na URL do psql.

2. Porta externa: não fixe 25444/25445 (já eram de lb-dev e

auditoria-dev-postgres na data acima). O app fica em outro projeto e cada

projeto tem sua própria rede bridge, sem DNS cruzado: DATABASE_URL/DIRECT_URL

apontam para 217.216.81.188:25446 (IP + porta publicados), não para

minipanel-render-prod-postgres.

3. 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.

4. 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 (medido em 2026-10-15): curl -fsS https://render-v0-api.mp.serendiped.com/health

responde 200 com {"db":{"ok":true}}; o campo de schema mora em /health/ready

({"checks":{"schema":{"status":"ok","missing":[]}}}) — /health sozinho não mostra

schema. npm run test:db:drift (em qualquer checkout) confere migrações × schema.

5. Ordem de subida

> Estado em 2026-10-15 (backend em produção, frontend bloqueado no P8):

> - Passo 1 (banco): FEITO — render-prod-postgres rodando, SHOW max_connections = 1200.

> Senha do role girada em 2026-10-15 (a anterior vazou em output de tool numa sessão);

> nova senha propaga para DATABASE_URL, DIRECT_URL e POSTGRES_PASSWORD, conferida

> por hash. A senha vazada está morta.

> - Passo 2 (backend): FEITO em 2026-10-15 — deploy Ty-Y27OfPW2hiJIPIs26g,

> gitSha 3c2aaed3567b23e423ce2ee4dcc762b066fd4690, imagem minipanel-render-backend-ws:latest.

> Boot: prisma migrate deploy aplicou 0001_render_init, listen na 4000,

> /health 200 com db.ok, /health/ready 200 com schema.ok. autoDeploy segue OFF.

> - Passo 3 (frontend): BLOQUEADO e confirmado pelo painel — deploy

> FWo6vBzmdnAtKwp5cUOWp falhou com

> ERROR: Dockerfile not found in the repository: /frontend/Dockerfile. O serviço ficou

> error, sem container (nada meio no ar). Só sobe com o P8.

> - Passo 4 (shot): não existe — shot/ não está em nenhuma branch.

> - Contas/pré-requisitos (§2): FEITOS — cliente OIDC render vivo na Conta,

> CONTA_PROVISIONING_SECRET igual nos dois lados (por hash), tile e papel

> apps["render"] nas duas empresas. Duas pendências de runtime (§2): o segredo de

> provisionamento só entra no container do conta-backend-ws no próximo deploy dele, e o

> resync do estado (empresa/membros) só depois do Render no ar — **agora que o backend

> está no ar, o resync já pode ser disparado.**

#PassoComo 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 bootlog do boot mostra o `migrate deploy` OK e o `listen` na 4000; `/health` 200 (`db.ok`) e `/health/ready` 200 (`schema.ok`). **`/metrics` e `GET /api/llms` respondem 401 sem credencial** (não são públicos); os públicos de doc são `GET /llms.txt` e `GET /llms-full.txt`
2b**Resync da Conta** (depois do 2 no ar, e só com o `CONTA_PROVISIONING_SECRET_RENDER` já no env do `conta-backend-ws`)Administração → Aplicativos → empresa → Render → *Reenviar estado*; as entregas aparecem `DELIVERED` em `/api/platform/provisioning/deliveries` e o Render loga `org.upserted`/`member.upserted`
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. **Hoje o build falha**: `frontend/` não tem `Dockerfile` nem `package.json` (P8) — ver §5
4**shot** (`render-shot-ws`) **por último**, e só se o preview for usadoo backend loga o health do shot; `SHOT_SERVICE_URL` responde; captura de preview funciona numa tela real
5**Segurança** (sempre depois de subir)sem credencial: `/api/agent-keys`, `/api/audit`, `/metrics`, `/api/auth/me` e `/mcp` respondem **401** (envelope `urn:render:problem:unauthorized`); nada sobra aberto (`/`, `/swagger`, `/docs`, `/env`, `/debug` também 401). Lista oficial de rotas públicas sai no boot em `publicRoutes`

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:

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.

conferido com npx npm@10.9.4 ci --dry-run (lock fora de sincronia já quebrou deploy).

apk_* da empresa responde o envelope {ok,...}; sem credencial, 401.

6. Rollback

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

ItemEstadoOnde
(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 P4pendente 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)

ItemO 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 proxiedantes 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ínculono 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 momentono 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 donofase 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 sumirfase 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