Instalação e artefatos
Parte do Runbook operacional. Comece pela triagem do índice.
Instalando
curl -fsSLO https://github.com/viralabs-dev/brabo/releases/latest/download/install.sh && bash install.sh
Baixe um arquivo, depois rode-o com bash. As duas formas mais curtas que parecem
equivalentes não são, e o script recusa as duas pelo nome, antes de
baixar qualquer coisa, imprimindo a linha acima:
- Nunca
curl … | sh. O motivo é mecânico, não de estilo: com o script chegando pelo pipe, ostdindo processo é o download, então qualquerreadlê bytes do próprio script ou bate em EOF. Um instalador que não consegue perguntar teria de escolher sozinho os locais das pastas na máquina de outra pessoa (RN-526). - Nem
sh -c "$(curl …)"— esta foi a forma documentada até a AT-083, e nunca funcionou. O script confere o próprio hash contra o manifesto assinado, e sobX -c "…"não há arquivo para calcular hash:$0é o nome do shell. Comdashcomosh(Debian, Ubuntu) ele morria ainda antes, noset -o pipefail. Não há chave para pular essa autoverificação, e não haverá (ADR 0150) — a correção é existir um arquivo. Rodá-lo sob um shell que não é bash também é uma recusa nomeada própria.
O script verifica a própria origem antes de fazer qualquer coisa — a assinatura
do checksums.txt da Release, e depois o próprio hash dentro desse
manifesto verificado. Uma falha em qualquer passo é uma recusa nomeada, nunca um aviso.
Ele precisa de um entre sha256sum (coreutils, Linux) ou shasum -a 256 (vem
com o macOS) para fazer qualquer disso, e resolve qual antes do primeiro
download — não no meio de uma checagem. Faltar os dois é uma recusa própria,
nomeando as duas ferramentas e dizendo que nada foi baixado; é uma dependência
faltando do sistema operacional, e deliberadamente não é redigida como
as recusas de hash abaixo. Essas duas são desfechos diferentes pedindo coisas
diferentes — uma se corrige instalando, a outra parando — e a medição da AT-091
é o que pôs a linha entre elas: sha256sum: command not found
no macOS aparecia como "o cosign baixado NÃO bate com o hash pinado neste
script. Isso não é um aviso: pare e investigue.", o que tanto bloqueava toda
instalação no macOS quanto ensinava o leitor a ignorar essa frase. O --print-plan
não precisa de nenhuma das ferramentas e declara quais o script aceita
(conferir-hash).
A detecção tem teto de 5s na chamada ao Docker que faz (docker compose ls
fala com o daemon, e um daemon lento ou parado atrás de um socket vivo
travaria tudo antes da primeira pergunta). Bater no teto não é
erro — é o mesmo desfecho de não ter Docker nenhum, e o resto
dos sinais ainda é lido.
Sem TTY ele informa e sai com 0: imprime o que achou e o
comando a rodar num terminal. Isso é deliberado, e é a mesma forma
que o consentir-base.mjs já tem.
O que ele nunca apaga: a sua base de projetos e a sua pasta de espelho. Volumes nomeados só vão com confirmação, listados um a um antes.
Ele sobe a stack a partir do próprio compose
(docker/docker-compose.install.yml), que pega as imagens de variáveis
e não constrói nada — e ele baixa esse compose da Release
(RN-570,
ADR 0160). Quatro arquivos
viajam com o instalador como assets brabo-install-*, no mesmo
checksums.txt assinado que cobre o binário do runner: o compose, o
postgres/init.sql e o ollama/pull-models.sh de que ele faz bind-mount, e o
backup/test-restore-compose.sh que a migração roda. Logo depois de verificar
a si mesmo — antes de qualquer pergunta, antes de escrever qualquer coisa — o script baixa
os quatro e confere cada hash contra esse manifesto. Três recusas nomeadas, todas
numa máquina intocada:
| a mensagem começa com | quer dizer | faça |
|---|---|---|
| "a Release não publica …" | a Release é anterior ao ADR 0160, ou o passo de publicação dela falhou no meio | instale a partir de um checkout do repositório naquela tag, ou espere a próxima release |
| "o manifesto assinado não cobre …" | o manifesto existe mas não tem linha para aquele asset | igual ao de cima — a Release está incompleta |
| "… NÃO bate com o manifesto assinado" | o arquivo baixado não é o que o manifesto assinado descreve | pare e trate como incidente; não tente contornar |
As cópias verificadas são escritas sob docker/ na pasta de onde você rodou o
script — a mesma pasta do .env — só quando são necessárias pela primeira vez.
O que já estava lá (o compose da versão anterior, numa atualização) é
substituído, nunca lido, e o script nomeia o que substituiu; de dentro de um
checkout git noutro commit isso deixa o git status sujo.
Releases publicadas antes do ADR 0160 não levam esses assets. O instalador delas ainda usa o caminho relativo, e a linha única acima falha num diretório vazio com "no such file or directory" depois de escrever o
.env(a lacuna que a RN-549 mediu). Para essas tags, rode o instalador a partir de um checkout do repositório na tag que você está instalando.
No caminho de migração a prova de restauração (
test-restore-compose.sh) recebe o.envda instalação porBRABO_ENV_FILE(passado ao Compose como--env-file), porque o Compose procura o.envao lado do arquivo de compose — não no diretório de onde você roda. Se você rodar a prova à mão contra uma instalação, exporteBRABO_ENV_FILE=/caminho/para/.envantes; um caminho que não existe é recusado, nunca substituído por outro arquivo. Uma prova que falha ainda não apaga nada (RN-530).
Duas origens:
install.sh # --source=ghcr (default): por digest, assinatura verificada
install.sh --source=local # buildx bake; exige árvore limpa numa tag
Os segredos são gerados uma vez e persistidos no .env com modo 600 — o
arquivo é criado vazio e trancado antes de receber conteúdo, porque uma janela
em que os segredos são legíveis por todos é exatamente o que este arquivo não pode ter.
Não há passo de migrate, e não deveria haver: o compose encadeia
api → migrate-api com service_completed_successfully, então o up --wait
espera as migrations. Um segundo lugar ordenando migrations seria uma segunda
fonte da mesma verdade.
Depois de a stack subir ele pergunta antes de afirmar: /health na api e no
engine, e só então diz que instalou.
Ele instala o agente local — o binário é verificado contra o
manifesto assinado e posto com install -m 0755, então não há chmod manual
(RN-531) — e pergunta UMA base, escrevendo-a nos
dois lados: BRABO_PROJECTS_BASE no .env e base no
runner.json do runner. Uma Release sem binário para esta plataforma não interrompe
a instalação: ela diz isso e aponta para abrir uma issue em
https://github.com/viralabs-dev/brabo-runner/issues (o runner sai só como binário, AT-774).
Sobre uma instalação existente ele migra ou para — um up em volumes
de outra versão é dano que não avisa. A ordem é backup →
PROVAR que restaura → perguntar → apagar → instalar → restaurar, e a prova no
meio é o que dá ao instalador o direito de apagar
(RN-530).
E ele sabe qual versão está substituindo (RN-542).
As imagens são resolvidas antes de a detecção rodar, então a pergunta nunca é
cega: o marcador registra a versao instalada, o manifesto leva a que
está para ser instalada, e a oferta é específica para a relação entre elas.
| relação | o que oferece | default |
|---|---|---|
| mais nova | "Atualizar 5.0.0 → 5.1.0?" | sim — é o que você veio fazer, e o backup provado a torna reversível |
| a mesma | "Já é a 5.0.0. Reinstalar do zero mesmo assim?" | não — não há ganho a oferecer |
| mais velha | a nomeia downgrade, avisando que migrations de banco não rodam para trás | não — pode não ter volta |
| desconhecida | um marcador do schema 1 não tem versão; ele diz isso, e não adivinha | não |
Qualquer que seja o ramo que você escolher, a instalação é apagada e recriada do zero em vez de iniciada por cima dos volumes antigos. Meia migração é pior que nenhuma.
Um install.sh chamado pelo menu do pnpm bootstrap (Docker › Instalar)
só informa o plano. Isso é mecânico, não preferência: um item de menu
roda com stdin em /dev/null para o menu poder continuar lendo teclas do mesmo
terminal, e este instalador é feito para perguntar. A nota no item leva
o comando de uma linha que instala de verdade.
O que ele não faz: ligar o broker de containers sem perguntar — veja o broker numa instalação logo abaixo. O agente local ele pareia, desde a RN-547: ele cria a primeira conta, gera a chave de dispositivo de máquina na máquina, registra a metade pública, carimba o
kide instala a unit de máquina (ADR 0155) — e quando um elo falha ele nomeia o comando a repetir, veja Quando o instalador não fecha a instalação. O que fica na tela do projeto é um pareamento preso a um projeto: essa chave de dispositivo e obrabo-runner.config.jsonainda vêm de lá (ADR 0118). Inspecione tudo cominstall.sh --print-plan, que não toca em nada.
Verificação: .github/workflows/install-e2e.yml roda a linha única acima numa
máquina limpa, sob um pty de verdade, em toda tag final — o instalador, a
stack, a primeira conta, a chave de máquina e o agente pegando o primeiro
projeto. Em todo PR os specs scripts/dev/install*.spec.ts provam as peças
que não precisam de Release: o plano e o estado (install.spec.ts), o
.env pelo próprio parser do Compose (install-env.spec.ts), os passos de fechamento
(install-fechamento.spec.ts), as invocações recusadas
(install-invocacao.spec.ts), os arquivos baixados
(install-arquivos-da-instalacao.spec.ts), a pergunta do broker
(install-broker.spec.ts), e que o próprio E2E não apodreceu
(install-e2e.spec.ts). O caminho de migração — backup, provar, apagar,
instalar, restaurar — não tem execução de ponta a ponta: a máquina do E2E é limpa, então a
pergunta nunca é feita, e só o repasse do .env da prova de restauração tem spec
(scripts/dev/prova-de-restauracao-env.spec.ts).
O broker de containers numa instalação
Projetos nos modos Container e Pasta montada rodam dentro de um container, e
quem sobe esse container é o broker
(ADR 0144). Sem ele nenhum dos modos
jamais executa: container_start termina failed com BrokerIndisponivelError
e os dev agents ficam em dev.blocked_by_container. O modo Runner não
depende dele — ali o agente local usa o Docker da sua máquina.
Desde o ADR 0162 o
broker é a quinta imagem publicada (ghcr.io/viralabs-dev/brabo-broker, por
digest, assinada como as outras quatro) e o compose de instalação tem o
serviço — desligado por padrão, sob o mesmo profile container-broker do
compose de validação. O instalador pergunta ("Ligar o broker de container?
[s/N]"), logo depois da base de projetos, e diz em texto o que ligá-lo
concede: o broker recebe o socket do Docker desta máquina, e quem
comanda o broker comanda o seu Docker. O que o contém são as cinco camadas
do ADR 0130: nenhuma porta publicada, uma rede
(internal: true, sem internet) que só a api alcança, o service token,
cinco operações sobre o container de UM projeto, e uma spec que o broker compõe
a partir do que o Arquiteto decidiu — não há requisição que ligue
privileged, rede do host ou um -v livre.
| resposta | o que acontece |
|---|---|
s / sim |
ele mede o grupo do socket de dentro de um container (a própria imagem do broker, sem rede, só leitura, o socket ligado com --mount para que um socket ausente seja erro em vez de uma pasta vazia criada no seu host), calcula onde o Docker guarda o volume da pasta gerenciada, e escreve COMPOSE_PROFILES=container-broker, BROKER_URL=http://broker:8090, DOCKER_GID e PROJECT_WORKSPACES_HOST_ROOT no .env — as quatro juntas |
Enter, n, qualquer outra coisa |
desligado. O .env não recebe nenhuma das quatro linhas, e o resumo final diz "Broker de container: DESLIGADO" |
| sem terminal | desligado, e ele diz isso — nenhuma pergunta é feita onde ninguém pode responder |
Duas recusas e duas pendências, todas nomeadas:
- "não consegui medir o grupo do socket do Docker…" — a medição falhou
(Docker rootless ou remoto: o socket não está em
/var/run/docker.sock, que é o caminho que o compose monta). Nada foi escrito. Rode de novo e responda não, ou corrija o que a mensagem cita do Docker. Ele nunca escreve um999adivinhado no lugar. - "… não é um socket …" — mesmo desfecho, mesma correção.
- pendência "a raiz da pasta gerenciada…" — o
<DockerRootDir>/volumes/brabo_project_workspaces/_datacalculado não bateu com oMountpointreal do volume depois de a stack subir (ou odocker infonão disse onde os volumes moram). Projetos Pasta montada funcionam; projetos Container não, até você corrigirPROJECT_WORKSPACES_HOST_ROOT— a mensagem leva o valor que o daemon informou e o comando para recriar o broker. - pendência "o broker de container: a api NÃO o alcançou…" — o serviço subiu
saudável mas a api não o alcançou na rede interna.
docker compose -f docker/docker-compose.install.yml logs brokerdiz por quê.
COMPOSE_PROFILES no --env-file é o que faz o up -d --wait subir o
broker sem flag nenhuma na linha de comando — medido no Compose v5.5.1. A
variável da imagem, BRABO_BROKER_IMAGE, é escrita ligando-o você ou
não: o Compose interpola o arquivo inteiro antes de filtrar por profile, então uma
variável obrigatória num serviço desligado ainda recusa o arquivo.
Ligando depois (mesma pasta do .env):
# 1. o grupo do socket COMO UM CONTAINER O VÊ — com a própria imagem do broker,
# sem rede; é este o número de que o compose precisa
docker run --rm --network none --read-only --entrypoint stat \
--mount type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock \
"$(grep '^BRABO_BROKER_IMAGE=' .env | cut -d= -f2)" -c '%F %g' /var/run/docker.sock
# esperado: socket <gid>
# 2. onde o Docker guarda o volume da pasta gerenciada
docker volume inspect --format '{{.Mountpoint}}' brabo_project_workspaces
# 3. acrescente ao .env (as quatro — nunca uma sem as outras)
# COMPOSE_PROFILES=container-broker
# BROKER_URL=http://broker:8090
# DOCKER_GID=<gid do passo 1>
# PROJECT_WORKSPACES_HOST_ROOT=<caminho do passo 2>
# 4. recrie — a api também, para ela pegar o BROKER_URL
docker compose -f docker/docker-compose.install.yml --env-file .env up -d --wait
# 5. pergunte antes de afirmar
docker compose -f docker/docker-compose.install.yml --env-file .env exec -T api \
node -e "fetch('http://broker:8090/health').then(r=>r.text()).then(console.log)"
# esperado: {"status":"ok","servico":"broker"}
Desligando: remova as quatro linhas do .env, depois remova o container do
broker e recrie a api sem BROKER_URL:
docker compose -f docker/docker-compose.install.yml --env-file .env \
--profile container-broker rm -sf broker
docker compose -f docker/docker-compose.install.yml --env-file .env up -d --wait
up --remove-orphans não faz o primeiro passo — medido: um serviço
sob um profile desligado continua definido no arquivo, então o container dele
não é órfão e continua rodando. Nada nos seus projetos é apagado; projetos Container
e Pasta montada param de executar, e a tela do projeto deixa de oferecê-los
(ADR 0161).
Verificação: ligá-lo na instalação está provado — a pergunta e
as recusas dela pelo scripts/dev/install-broker.spec.ts em todo PR, e o caminho
inteiro pelo .github/workflows/install-e2e.yml em toda tag final, que responde
sim e exige o broker ligado, a api o alcançando, e COMPOSE_PROFILES e
BROKER_URL escritos no .env. Ligá-lo depois pelos cinco comandos acima, e desligá-lo,
não têm verificação: nada os roda, e a ressalva do
up --remove-orphans foi medida uma vez à mão.
Modelos locais numa instalação
Logo depois do broker, o install.sh pergunta Ligar os modelos locais? [s/N]
(RN-691). Ele diz o custo antes — disco para a
imagem do Ollama e os modelos que o ollama-model-loader baixa ao iniciar, RAM enquanto um
modelo está carregado — e o que se perde sem eles: a checagem de duplicata semântica
de histórias e regras é pulada, e os agentes não têm modelo local. Só um
"s" digitado os liga; sem terminal eles ficam desligados.
Ligá-los mede a porta do host antes de escrever qualquer coisa: 11434, ou a
primeira livre até 11443 (um Ollama nativo costuma segurar a 11434). A faixa
inteira ocupada é uma recusa nomeada sem nada escrito. O .env recebe OLLAMA_PORT
e llm na linha única de COMPOSE_PROFILES.
Para ligá-los depois, na pasta da instalação: escolha uma porta livre, depois
acrescente OLLAMA_PORT=<porta> ao .env, acrescente llm à linha existente de
COMPOSE_PROFILES com uma vírgula (ou crie COMPOSE_PROFILES=llm), e rode
docker compose -f docker/docker-compose.install.yml --env-file .env up -d --wait.
Verificação: a pergunta, a medição da porta e as linhas do .env são
provadas pelo scripts/dev/install-llm.spec.ts em todo PR. O E2E da instalação
responde não (baixar modelos custaria GBs por tag), então subir com o
profile llm não tem verificação de ponta a ponta.
Reinstalando do zero
Quando uma instalação anterior é encontrada (só conta o projeto compose chamado exatamente
brabo — o compose de dev brabo-dev não,
RN-692), a migração roda o backup, prova o
restore, e então pergunta
Apagar os volumes e reinstalar? [s = restaurar / z = do zero / N = parar].
z apaga e reinstala sem restaurar os dados antigos; o backup provado
fica na pasta que ele nomeia, e restaurá-lo depois é o
Restore. Provado pelo scripts/dev/install-llm.spec.ts.
Verificando um artefato publicado
Toda tag final assina o que publica, com cosign keyless — a
identidade que assina é o próprio workflow, e não há chave sob custódia
em lugar nenhum (ADR 0149,
RN-524). É assim que você confere, fora do
CI.
Os dois comandos precisam das duas flags --certificate-*. Elas não são
enfeite opcional: sem elas o cosign aceitaria uma assinatura válida de
qualquer um, que é a falha que todo este mecanismo existe para evitar.
Uma imagem, por digest
# O digest vem do .release/images.json, que é um asset da Release
# — nunca de uma tag, que é um ponteiro móvel.
DIGEST=$(gh release download vX.Y.Z --pattern images.json --output - \
| jq -r '.imagens[] | select(.alvo == "api") | .digest')
cosign verify \
--certificate-identity-regexp '^https://github.com/viralabs-dev/brabo/\.github/workflows/release\.yml@' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
"ghcr.io/viralabs-dev/brabo-api@${DIGEST}"
Os binários do runner
Um checksums.txt assinado cobre os quatro alvos (o Mac Intel não tem binário
desde o ADR 0174 — ele fica
sem runner e pede suporte por issue no brabo-runner, AT-774), então a verificação tem dois
passos: a assinatura do manifesto, depois o binário contra o manifesto.
gh release download vX.Y.Z --repo viralabs-dev/brabo-runner --pattern 'checksums.txt*' --pattern 'brabo-runner-*'
cosign verify-blob \
--bundle checksums.txt.bundle \
--certificate-identity-regexp '^https://github.com/viralabs-dev/brabo-runner/\.github/workflows/build-runner-binaries\.yml@' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
checksums.txt
# Só depois de o manifesto ser provado autêntico é que conferir o binário quer
# dizer alguma coisa — um checksums.txt que ninguém verificou é um arquivo que o
# atacante também consegue escrever.
sha256sum -c checksums.txt --ignore-missing
Quando a verificação falha
Não instale. Uma assinatura que não verifica tem exatamente duas
leituras — o artefato não é o que a esteira publicou, ou a esteira
não o publicou — e nenhuma é motivo para continuar. Confira se a
regex de identidade casa com o workflow que de fato publicou aquela tag (uma
republicação por workflow_dispatch assina com a mesma identidade), e então
trate como incidente e não como ruído.
O que isto NÃO cobre: assinatura de código do sistema operacional para os binários do runner — notarização do macOS e Authenticode do Windows. Essas exigem uma identidade de assinatura paga e são um item de backlog separado; o sistema ainda vai avisar na primeira execução.
A oferta escrita de fonte, dentro de todo artefato publicado
Assinar responde "isto é o que a esteira publicou?". Uma segunda pergunta viaja com a mesma imagem e tem outra resposta: onde está a fonte do software GPL dentro dela?
A imagem do engine embute git, busybox, libgcc/libstdc++ e outros 18 pacotes
apk sob GPL, mais os quatro scanners que o engine chama por exec.
Rodá-los como processos separados não contamina o Brabo, que continua
MIT — mas distribuir a imagem exige que quem a recebe possa obter
a fonte correspondente. Desde o ADR 0119
essa distribuição acontece em toda tag final.
A oferta escrita é o THIRD_PARTY_NOTICES.md, e ele vai dentro da
imagem, porque quem roda docker pull não tem o repositório:
docker run --rm --entrypoint sh ghcr.io/viralabs-dev/brabo-engine:vX.Y.Z \
-c 'cat /usr/share/doc/brabo/THIRD_PARTY_NOTICES.md'
Ele nomeia todo componente, a versão exata e a licença, que é o que permite a qualquer um chegar à release upstream de cada um.
Desde a AT-120 o mesmo arquivo, no mesmo caminho e na mesma forma (dono root,
0644, copiado antes do USER), vai dentro de toda imagem publicada —
brabo-api, brabo-web, brabo-broker e brabo-backup além de
brabo-engine — e ao lado dos binários do runner como o asset
THIRD_PARTY_NOTICES.md da Release, coberto pelo mesmo
checksums.txt assinado (RN-524). Essa foi a decisão do mantenedor, e é a
conservadora: nenhum artefato é declarado isento. Troque brabo-engine por qualquer
das outras quatro no comando acima. Antes da AT-120 esse comando falhava com
"Permission denied" na imagem do engine: o BuildKit aplica COPY --chmod=0644
também ao diretório que o COPY cria, então /usr/share/doc/brabo não tinha
bit de execução e o usuário não-root da imagem não conseguia atravessá-lo. Cada
Dockerfile agora cria o diretório 0755 antes. Para o runner:
gh release download vX.Y.Z --repo viralabs-dev/brabo \
--pattern THIRD_PARTY_NOTICES.md --pattern checksums.txt
sha256sum -c --ignore-missing checksums.txt
Se esse arquivo falta num artefato, o artefato não deveria ser
distribuído. O scripts/ci/oferta-de-fonte-na-imagem.spec.ts impede que cada COPY
e o asset da Release sejam removidos por acidente, e o passo
A oferta de fonte está dentro das cinco imagens do ci.yml lê o
arquivo de volta de cada imagem que constrói e o compara com o checkout — mas
só uma tag de verdade prova o artefato publicado.
O arquivo também registra o que não está resolvido: separar os scanners num sidecar próprio, deixando a imagem do engine livre de copyleft, segue aberto como escolha de arquitetura e não de conformidade.
Subindo uma imagem de terceiro
Toda imagem de terceiro em docker/ e deploy/k8s/ é fixada por
digest, com a tag de onde veio escrita dentro da referência, antes
do digest (ADR 0159,
ADR 0178). Os workflows em
.github/workflows/ não guardam literal de imagem: os services: deles leem a
referência do compose de dev pelo imagens-do-compose.yml
(ADR 0197), então subir
o compose sobe o CI também:
image: neo4j:5.26-community@sha256:d9cfe82983d27f5a75b3aaae8f316d04f9a698a3b7f6103a508f7caf8362f255
Um Dockerfile toma a mesma forma na linha FROM — nunca um comentário no
fim dela: o parser do Docker só aceita # no começo da linha, e um
no fim faz o build falhar com "FROM requires either one or three
arguments":
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6 AS runtime
Isso é um congelamento. Desde o ADR 0197 os ecossistemas docker-compose (os
composes em docker/) e docker (as linhas FROM em docker/*/ e os
manifestos em deploy/k8s/) do Dependabot propõem a subida toda semana, uma PR agrupada
por ecossistema, na dev. Revisar essa PR são os passos 1 e 4 deste procedimento.
Dois casos ainda voltam para cá:
neo4jeollamamoram num compose E emdeploy/k8s/base/, e os dois ecossistemas nunca dividem uma PR. Quando o registry republica a MESMA tag com outro digest, as duas PRs reprovam no lint ("mesma tag inline, dois digests"); junte as duas subidas numa PR só com o passo 3 abaixo.- As duas imagens que nenhum ecossistema lê (abaixo).
Para mover uma à mão — digamos, neo4j de 5.26-community para 5.27-community:
# 1. Confirme que a tag resolve para um ÍNDICE (uma manifest list). Se não,
# o pin perde o multi-arch e o linux/arm64 para de funcionar.
docker manifest inspect neo4j:5.27-community | head -3
# → "mediaType": "application/vnd.oci.image.index.v1+json"
# (ou …distribution.manifest.list.v2+json)
# 2. Leia o digest do índice.
docker buildx imagetools inspect neo4j:5.27-community --format '{{.Manifest.Digest}}'
# 3. Escreva `neo4j:5.27-community@<digest>` EM TODO LUGAR onde a referência antiga aparece.
# Não em .github/workflows/: os workflows leem pgvector e ollama do
# compose de dev (ADR 0197), e o lint reprova um literal ali.
grep -rn 'neo4j:5.26-community@sha256' docker/ deploy/k8s/
# 4. O lint prova.
node scripts/ci/imagens-pinadas.ts
Duas imagens são subidas por este mesmo procedimento, à mão, faça o Dependabot
o que fizer: o imageName do CloudNativePG
em deploy/k8s/overlays/local/db/cluster.yaml (nenhum ecossistema
lê essa chave) e a imagem do caso de QA do golden-set,
IMAGEM_DO_GOLDEN_SET_QA em apps/api/scripts/golden-set-qa-container.ts
(uma constante TypeScript, lida pelo golden-set-qa.yml).
O passo 3 não é burocracia opcional: a checagem recusa a mesma tag inline levando dois digests diferentes, porque o compose de dev e o cluster afirmando a mesma versão enquanto rodam bytes diferentes é como um CI verde deixa de querer dizer alguma coisa. Entre o compose de dev e o CI não há mais nada a alinhar: o CI lê o compose.
Para dar a um workflow NOVO um serviço de terceiro, nunca escreva a referência no
workflow: acrescente a imagem do serviço em docker/docker-compose.yml, uma linha em
IMAGENS_DOS_WORKFLOWS em scripts/ci/imagens-do-compose.ts, a saída
correspondente em .github/workflows/imagens-do-compose.yml (o spec dele reprova se os dois
divergem), e no workflow um job imagens: com
uses: ./.github/workflows/imagens-do-compose.yml mais
image: ${{ needs.imagens.outputs.<nome> }}.
O que a checagem não cobre, e por quê, está em
a cadeia de suprimentos do CI:
as cinco imagens que o produto publica já estão resolvidas por digest pelo
.release/images.json e pelo make imagens-do-release
(ADR 0119) — nunca fixe
essas à mão no overlay, que deliberadamente guarda o marcador.
Acrescentando um provider compatível
Vale para qualquer provider que fale o dialeto /chat/completions da OpenAI —
que é o caso de praticamente todo hub e todo serviço de inferência
gerenciado. A base já existe
(ADR 0041);
o que você escreve é configuração, não parsing.
1. Leia a doc oficial antes de escrever a primeira linha
Quatro coisas precisam vir da documentação do provider, não de palpite:
baseUrl, o cabeçalho de auth, o formato do usage no stream, e as
peculiaridades do streaming. Registre a URL consultada e a data no topo do
arquivo de config — é o único jeito de saber, meses depois, se a config
está velha.
O que diverge do padrão da OpenAI vira uma flag na base,
nunca um if espalhado. Se a divergência não cabe numa flag
existente, acrescente uma — e só porque este provider real precisa.
2. Escreva a config
// apps/api/src/infrastructure/llm/<provider>-provider.ts
export function meuProviderConfig(baseUrl = BASE_URL): OpenAICompatibleConfig {
return {
name: 'meu-provider',
baseUrl,
capabilities: { streaming: true, toolCalling: true, listModels: true },
authHeaders: (apiKey) => ({ Authorization: `Bearer ${apiKey ?? ''}` }),
flags: { streamOptionsIncludeUsage: true, maxTokensField: 'max_tokens' },
// Só se o catálogo dele devolve mais que `{ data: [{ id }] }`:
// parseCatalogo: (corpo) => ...,
// Só se for um HUB (informa quem de fato serviu a requisição):
// extrairUpstreamProvider: (frame) => ...,
};
}
Exporte a função de config, não só a classe: é para ela que a suíte de contrato aponta o servidor falso. Uma cópia da config escrita dentro do teste passaria verde mesmo se a de produção divergisse.
3. Rode a suíte de contrato contra ele
runLLMProviderContract('meu-provider', () => ({
dialeto: dialetoOpenAI, // reaproveite o da base se o formato é o mesmo
criar: (baseUrl) =>
new OpenAICompatibleProvider(meuProviderConfig(baseUrl), new GptTokenizerEstimator()),
usageFallback: 'estimated',
timeoutEnv: 'LLM_REQUEST_TIMEOUT_MS',
temFerramentasNoPedido: (body) => Array.isArray(body.tools),
modelo: 'algum-modelo',
}));
Herdado de graça: streaming com frame partido, usage presente e ausente, tool calling, os quatro erros normalizados, o catálogo, e um servidor mudo.
4. Registre o provider e o tipo de credencial
- dois lugares, de propósito: o tipo
LLMProviderNameempackages/shared/src/index.ts(o web o usa também) e a lista de runtimeLLM_PROVIDER_NAMESemapps/api/src/domain/llm/llm-provider-names.ts. Eles não podem morar juntos:packages/sharedé 100% tipos — um valor exportado de lá quebra a imagem de produção da api no boot comERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING, eapps/api/test/packages-shared-so-tipos.spec.tsreprova antes de chegar lá. Esquecer a lista não passa em silêncio: a checagem de exaustividade nos dois sentidos quebra o typecheck, assim como oRecordexaustivoROTULO_DO_PROVIDERdo web quebra até o provider ganhar um rótulo; - se for um hub, acrescente o nome a
HUBSemapps/web/src/lib/models.tspara ele cair no grupo certo do seletor; - o registro de providers da api (
llm-infrastructure.module.ts); - o
pgEnumdo provider no schema mais uma migration, se o nome é novo.
5. Semeie os modelos com o preço da doc
Um preço digitado vem com manual_pricing: true. Isso protege a linha
da sincronização de preço: para um provider que não expõe preço no
catálogo, o número manual é o único que existe.
Se o provider expõe GET /models, não semeie o catálogo inteiro —
deixe a sincronização descobri-lo. Ela escreve os modelos desabilitados, e
o owner habilita o que importa pela tela de curadoria
(RN-043).
Se o catálogo do provider publica modalidade (aceita imagem, gera
imagem) ou reasoning, emita-as no parseCatalogo dele — e só quando
a doc oficial diz. Um campo que o provider não declara fica
omitido, nunca false: undefined preserva o que já estava
gravado, e false apagaria dado curado à mão
(RN-056).
6. Verifique com uma credencial real
# na página Providers (rodapé do menu): cadastre a credencial, depois
# "Atualizar catálogo" e confira o relatório por provider.
O relatório mostra todo provider, inclusive os pulados, com o
motivo e a origem da falha. sem_credencial quer dizer que a chave
nunca chegou; falha · origem infra quer dizer que nem chegou ao
provider; falha · origem modelo quer dizer que ele respondeu com uma recusa.
Verificação: os passos 2–4 são provados em todo PR — a suíte de contrato
(apps/api/test/contract/llm-provider.contract.ts, rodada pelo
*.contract.spec.ts de cada provider) contra um servidor falso, e o typecheck que quebra quando
falta um nome em qualquer das listas. O passo 6 é manual por natureza: ele precisa de uma
credencial real com crédito, que o CI não tem. Os arquivos *.smoke.spec.ts por
provider são a forma roteirizada dele, e pulam sem uma
chave — o ambiente tem credencial só para o OpenRouter (veja o
TODO(humano) sobre os smokes de LLM no CLAUDE.md).