Pular para o conteúdo

Decisões de arquitetura

Por que o Oren é do jeito que é. Cada decisão aqui tem uma alternativa que foi considerada e rejeitada por um motivo específico — o motivo importa mais que a decisão, porque é ele que diz quando revisitar.


Decisão. Um contrato (Task) declara o que entra, o que sai e de que recursos precisa. Uma implementação (Implementation) declara qual imagem faz o trabalho e onde cada recurso é montado.

Por quê. A v0 tinha um TaskBuilder que misturava os dois lados:

builder.addVolumeMap("source", "/source")

"source" é nome de contrato; "/source" é caminho de implementação. Como estavam juntos, as definições de task só podiam viver dentro da CLI — e por isso o projeto não conseguia ter um catálogo.

A linha exata. “Preciso de um repositório git” é da task. “Monto em /source” é da implementação. “Preciso de uma credencial GCP” é da task. “Ela vai em /keys/gcp-credential.json” é da implementação.

Consequência que compensa. Duas implementações do mesmo contrato são intercambiáveis por uma linha no pipeline. Verificado com analyze-commits: as versões shell e Node produzem saída byte a byte idêntica.


Decisão. Diretórios, credenciais e engines ficam em dependencies, cada uma com um type de um vocabulário fechado.

Alternativa rejeitada. Campos separados (workspaces, secrets, capabilities), que foi a primeira versão do spec.

Por quê. Como três campos, não existe a pergunta “qual implementação pede menos”. Como uma lista tipada, existe — e é a base do ranking no portal. Foi o próprio usuário quem apontou isso, e a proposta original era pior.

Dois eixos por tipo.

  • Semânticao que é (secret/gcp-service-account). O autor da task declara isso.
  • Formacomo chega (file, socket, string, directory). Derivada do tipo pelo registry.

Derivar a forma da semântica evita declarar duas vezes e é o que permite o gerador de CI traduzir credenciais corretamente.

Piso e delta. A Task declara o mínimo que qualquer implementação vai precisar. A Implementation declara o que ela adiciona. Duas implementações só são comparáveis porque o piso é idêntico — o que sobra é escolha de engenharia.

Demonstrado na prática: nenhum contrato exige engine/docker. A implementação anterior de build usava o socket do host (privilégio critical); a nova usa kaniko e não pede nada além do piso.


Decisão. none, reads-external, external.

cacheável repetir é seguro
none sim
reads-external não sim
external não não

Por quê. O motor de execução cacheia agressivamente. Para uma task que publica imagem, servir do cache significa que o push não aconteceu — com o pipeline reportando sucesso. Demonstrado: um npm-publish executado duas vezes recebeu 409 Conflict do registry na segunda, provando que reexecutou de verdade.

reads-external surgiu do terraform-plan: não altera nada, mas lê estado remoto e infraestrutura real. Cachear devolveria um plano calculado sobre um mundo que já mudou. A distinção não muda o cache — nenhum dos dois é cacheável — mas muda o risco, e é o que permite ao portal mostrar que planejar é seguro repetir e aplicar não.

Critério. Se eu rodar de novo com as mesmas entradas e nada acontecer, o pipeline ainda está correto? Se não, é external.

Assimetria dos erros. Marcar external a mais custa cache. A menos produz um deploy silenciosamente ausente. Na dúvida, external.


Decisão. /oren/input.json e /oren/output.json. stdout e stderr são log livre.

Alternativa rejeitada. A v0 passava JSON em argv e lia a saída de stdout delimitada por marcadores.

Três problemas que isso resolveu de uma vez.

  1. Escaping. JSON em argv atravessa o shell do entrypoint; $, aspas e quebras de linha exigiam escaping que nenhum lado fazia direito.
  2. Colisão. stdout era canal de log e de dados ao mesmo tempo. Um worker que imprimisse o delimitador corrompia a saída.
  3. Vazamento. Tokens em argv aparecem em ps e em qualquer eco do comando.

Ganho não previsto. O protocolo ficou agnóstico de runtime, o que tornou a geração de CI possível sem mudar nada. Um worker que lê $OREN_INPUT_PATH roda igual pela CLI ou por um job do GitLab.


Decisão. Todo worker executa como imagem OCI. A linguagem é escolha de build-time, resolvida por SDK e template.

Alternativa rejeitada. Runtimes por linguagem (container, node, python…), que chegou a estar no spec.

Por quê. Foi o usuário quem notou: os “runtimes” que eu tinha modelado não eram runtimes — eram linguagens. Com um caminho de execução só, some a árvore transitiva do npm no lockfile, some a segunda superfície de supply chain, e adicionar uma linguagem deixa de ser mudança de spec.

A distinção é temporal.

Build-time Execution-time
O que é SDK / template Imagem OCI + protocolo v1
Quem vê quem escreve o worker CLI, portal, lockfile

O SDK se distribui pela imagem base, não por gerenciador de pacotes. Um “SDK shell” são quatro funções copiadas para a imagem — não precisa de npm. Isso generaliza para qualquer linguagem, inclusive as sem registry conveniente.


Decisão. Implementações compartilham imagem e se distinguem pelo runtime.entrypoint.

Alternativa rejeitada. Um input comando no contrato, para uma task fazer plan ou apply.

Por quê. terraform-plan e terraform-apply têm effects diferentes, o plano é output de um e input do outro, e o diretório de artefatos é mutável só no primeiro. Um contrato único teria que declarar o pior caso em effects e perderia a informação de que planejar é seguro repetir.

Mas contratos separados não exigem imagens separadas: as duas apontam para oren-ci-terraform-hashicorp e escolhem /worker/plan.sh ou /worker/apply.sh.

Formulação. Subcomando é entrypoint, e quem escolhe é a implementação — não um input do contrato.


Decisão. Outputs são gravados em .oren/runs/<id>/ e relidos dali, mesmo na execução local.

Por quê. É o que faz o modo local ser um caso particular do modo gerado, onde cada step vira um job separado e nada sobrevive entre eles além do que foi materializado. Sem isso, a classe de bug “funciona na minha máquina, quebra no CI” existiria por construção.

O que a invariante proíbe. Passar referências vivas entre steps (handle de container, socket, objeto do SDK), outputs não serializáveis, e qualquer cache da CLI que não esteja no lockfile ou em caminho declarado.

Exceção que precisou de tratamento. Outputs marcados secret não são persistidos — gravar credencial em texto no .oren/ desfaria a garantia de que material sensível não toca o disco. O resume trata steps dependentes de valor secreto como não-retomáveis.


Decisão. Container (oren-engine-<versão>) e volume (oren-cache-<versão>) próprios, com a imagem oficial do Dagger.

Por quê. O Dagger roda um engine compartilhado por máquina. Sem isolamento, oren cache prune apagaria o cache de qualquer outro uso de Dagger do usuário, e oren cache limit reescreveria a configuração global dele. O Dagger é detalhe de implementação do Oren e não deveria ter consequências fora dele.

Não é um fork. Mesma imagem, mesmo digest. O que muda é o --name e o volume, escolhidos pelo SDK via _EXPERIMENTAL_DAGGER_RUNNER_HOST.

Riscos aceitos. A variável tem prefixo _EXPERIMENTAL_ e pode mudar sem aviso. E o cache não é compartilhado com outros usos de Dagger — layers comuns são baixadas duas vezes.

Versionamento. Container e volume carregam a versão porque o formato do cache do BuildKit não garante compatibilidade entre versões do engine. Versões antigas são removidas ao subir a nova, senão cada atualização deixaria um volume órfão.


Decisão. O oren.lock guarda referência com digest (repo@sha256:...) ou o digest do conteúdo do build.

Por quê. O consentimento é ancorado na identidade do worker. Antes do lockfile, essa identidade era a tag — e tags são mutáveis. Republicar alpine:3.20 com outro conteúdo mantinha o fingerprint intacto, e a autorização concedida a uma imagem valia para outra que ninguém revisou. O mecanismo apresentado como proteção contra escalada de privilégio tinha exatamente esse furo.

Para build local, o digest é do conteúdo do diretório: mover o worker não muda a identidade, alterar o conteúdo muda.


Decisão. .oren/consent.json, com fingerprint sobre identidade do worker + conjunto de dependências.

Por quê ser um arquivo. Um prompt a cada execução vira um “sim” automático. Gravado, o time herda a decisão e uma escalada de privilégio aparece como diff em code review — revisão de privilégio deixa de ser um prompt e vira parte do fluxo que já existe.

Por que o fingerprint cobre as dependências. Imagem nova, dependência nova, ou diretório que passou a ser mutável produzem chave diferente e obrigam nova autorização.

Fadiga. Dependência de privilégio low não pergunta. Ler um diretório não merece cerimônia, e cerimônia demais é o que faz o mecanismo deixar de ser lido.

Em CI, a decisão é falhar. Sem terminal e sem autorização gravada, o pipeline para. --yes existe mas é opt-in explícito. Privilégio novo não deve ser concedido sozinho.


Decisão. Uma organização publica um contrato, qualquer outra pode implementá-lo.

Por quê. É o que evita o problema original voltar em outra escala: se cada empresa escrevesse o próprio contrato, teríamos dez versões incompatíveis de build-docker-image.

Três regras que decorrem.

  • Namespace obrigatório para publicar. Sem ele, o segundo analyze-commits já colide.
  • Versão publicada é imutável. Republicar quebra quem depende, sem sinal.
  • Não existe deleção, só depreciação. Já sabemos como termina a alternativa.

Consequência sobre implementações oficiais. Elas são de referência, não recomendadas. Ser “oficial” não pode virar peso no ranking — do contrário o ranking deixa de medir privilégio e passa a medir procedência, e o incentivo de terceiros publicarem desaparece.


O portal guarda o documento, não uma normalização dele

Seção intitulada “O portal guarda o documento, não uma normalização dele”

Decisão. O task.yaml é armazenado íntegro. Índices para busca e listagem são derivados dele.

Alternativa rejeitada. Normalizar em tabelas — uma para inputs, outra para dependências, e assim por diante.

Por quê. Normalizar é reimplementar o spec dentro do banco, e aí existem duas definições do que é uma task. Elas divergem na primeira mudança de schema, e a divergência aparece como bug de quem consome. Com o documento íntegro, o spec continua sendo a única fonte, portal e CLI validam contra o mesmo JSON Schema, e os índices são cache recriável.

A interface renderiza o documento — como as páginas do catálogo já fazem, geradas do task.yaml.

Decisão. A API responde três perguntas: resolver namespace/nome@range para um contrato, listar as implementações de um contrato com o delta de dependências, e devolver imagem e digest de uma implementação.

O que isso proíbe. Endpoints que executem pipeline, agendem, ou guardem estado de execução como fonte da verdade. Depois do oren install, um build não pode depender do portal — é o que o lockfile garante, e o que mantém a promessa de que o pipeline não pertence a nenhuma plataforma.

Consequência de ordem. Desenhar essa API antes do modelo de dados. O caminho inverso produz uma API que espelha o banco em vez de servir o cliente.

Decisão. Publicar uma implementação não exige verificação de que as dependências declaradas correspondem ao que a imagem faz.

Por quê. A defesa já existe e é melhor: a CLI concede exatamente o declarado. Uma implementação que use o socket do Docker sem declarar engine/docker falha ao executar. Sub-declarar não rende vantagem no ranking — rende um worker quebrado.

Verificar no portal exigiria executar a imagem em ambiente controlado, o que é caro e ainda assim burlável (basta o comportamento depender de input). Não compensa antes de haver abuso real.

Decisão. workers/ é um repositório único.

Por quê. Eles mudam juntos. Alterar um tipo no registry ou adicionar um campo ao spec exige revisar todos os contratos no mesmo movimento — separados, viraria onze mudanças coordenadas.

Critério de saída. Quando um worker tiver ritmo de release próprio, mantenedor diferente, ou virar dependência de terceiros, ele sai. Monorepo → polirepo é barato nessa direção; o contrário não é.


Decisão. O schema de pipeline exige implementation. Não há default — nem quando o contrato tem uma única implementação registrada.

Por quê. Omitir era uma quebra à distância. Com uma candidata só, o step funcionava; no dia em que alguém publicasse uma segunda — outra pessoa, outra organização, outro repositório — o pipeline passava a falhar sem que ninguém tivesse tocado nele. É o mesmo não-determinismo que o oren.lock existe para eliminar, sobrevivendo num lugar onde não estávamos olhando.

O que roda tem que ser função do que está escrito no repositório, não do que existe publicado no momento da execução.

Alternativa considerada e recusada. Default para a implementação da mesma organização que publicou o contrato. Falha em dois pontos: colide com a decisão de que procedência não pesa no ranking (seria o peso máximo — não muda a ordem, decide), e não resolve o caso real, porque a mesma organização pode ter várias. techlite/analyze-commits tem duas, ambas low, ambas com zero dependências adicionais: nenhum desempate disponível significa alguma coisa. E um desempate arbitrário é pior que nenhum — parece intencional.

Consequência sobre o oren add. Ele passou a escrever o step no oren.yaml em vez de imprimir um snippet para copiar. A escolha continua sendo humana e feita uma vez só; o que muda é que ela fica registrada no arquivo em vez de depender de alguém ter lido a saída do comando. Com mais de uma candidata ele pergunta, mostrando o custo de cada uma; fora de TTY exige --impl.

Custo aceito. Verbosidade. Todo step ganha uma linha que, no caso de implementação única, não carrega escolha nenhuma — só a torna explícita.


Decisão. ValidadorDeDocumentos recusa qualquer documento que não satisfaça o schema do spec, antes de ele entrar no catálogo.

Por quê. O portal serve o documento íntegro e toda CLI o valida do outro lado contra o mesmo schema. Aceitar um documento inválido é publicar uma bomba: quem resolver aquela versão recebe algo que a própria ferramenta dele recusa, e o erro aparece na máquina de quem consome — longe de quem publicou, e sem que ele fique sabendo.

O Javadoc já afirmava que essa validação existia. Não existia: só o kind era conferido, e os schemas ficavam sincronizados nos resources sem uso.

Custo aceito. A lib (networknt/json-schema-validator) traz Jackson 2 junto, e o projeto usa Jackson 3. Os dois convivem — pacotes distintos — e a travessia acontece num único ponto, dentro do validador. A alternativa era reimplementar JSON Schema.

O que isso NÃO resolve. O caso inverso: documento válido para um portal novo e inválido para uma CLI antiga, porque os schemas do spec são estritos. Ver Compatibilidade.


Decisão. Uma task ou implementação publicada como pública nunca pode virar privada. A transição existe só no sentido inverso: privada → pública.

Por quê. Tornar privado o que já era público quebra todo pipeline que o referencia, em máquinas que ninguém tocou, e sem sinal — o oren install de amanhã falha resolvendo algo que resolvia ontem. É a mesma classe de problema que a imutabilidade de versão evita, com um agravante: a versão continua existindo, só deixou de ser alcançável.

Isso vale mesmo quando quem publicou tem todo o direito de mudar de ideia. O dano não é dele.

Consequência para o modelo pago. Contratos e implementações privados são o adicional que faz sentido cobrar — uma organização publica no registry sem expor ao mundo. Mas a privacidade é uma escolha do momento da publicação, não um interruptor. Uma conta que deixe de pagar não pode ter o que era público retirado do ar; o que se perde é a capacidade de publicar coisas novas em privado.

O que isso impõe ao schema. A visibilidade é do documento publicado, não da organização. Se fosse um campo da organização, mudar o plano dela mudaria a visibilidade de tudo que ela já publicou de uma vez — exatamente o que esta decisão proíbe.

Adiado, não descartado. Cobrança, planos e faturamento fazem parte do plano — o que não faz sentido é encará-los agora. Sozinhos, dão um projeto do tamanho do registry inteiro (meios de pagamento, ciclo de assinatura, inadimplência, fiscal), e começá-los antes de o registry ser usável adiaria o que ainda falta aqui.

O que esta decisão garante é que adiar não custa caro: a regra “uma vez público, sempre público” e a visibilidade morando no documento são o que impede que o modelo de cobrança, quando existir, precise mexer no que já foi publicado.


Namespace é plano; times separam permissão, não nome

Seção intitulada “Namespace é plano; times separam permissão, não nome”

Decisão. Não haverá sub-namespaces (acme/plataforma/build). Uma organização com vários times ou tem uma organização por time (acme-plataforma, acme-dados), ou usa times internos — que controlam quem publica o quê, sem aparecer no identificador.

Por quê. O namespace aparece em toda referência de todo pipeline que usa a task. Um nível a mais o transforma num reflexo do organograma — e organogramas mudam. Um time que se reestrutura mudaria a referência de tudo que já publicou, o que colide de frente com uma vez público, sempre público: a versão continuaria existindo, com outro nome, e quem a referenciava pararia de encontrá-la.

A estrutura interna de uma empresa não deve vazar para um identificador que terceiros escrevem nos próprios arquivos.

Precedente. npm e GitHub separam as duas coisas: times existem e controlam permissão, mas o nome do pacote continua plano (@acme/x). O GitLab misturou — subgrupos mudam o caminho — e é a fonte recorrente de “onde está X?” e de migrações caras quando um time se move.

O que fica em aberto, e é a necessidade real. Hoje o papel é da organização inteira: quem pode publicar, publica tudo. “Só o time de plataforma publica acme/build” é permissão por recurso, e não precisa de pasta nenhuma para existir. É o que falta no modelo — não um nível a mais no nome.

Agrupar para navegar — 200 tasks numa organização grande — é apresentação. Resolve-se com tags e filtros na interface, sem tocar no identificador.

Custo aceito. Uma organização que queira namespaces separados por time precisa criar organizações separadas, e membros se repetem entre elas. É mais cadastro; em troca, nenhuma reorganização interna quebra a referência de ninguém.


Mantenedores restringem por task; ausência deles não restringe nada

Seção intitulada “Mantenedores restringem por task; ausência deles não restringe nada”

Decisão. Uma task pode ter mantenedores. Se tiver, só eles publicam versões novas dela. Se não tiver, vale o papel na organização.

Por quê. É a necessidade real por trás da pergunta sobre times — “só o time de plataforma publica acme/build” — resolvida sem tocar no identificador. Ver namespace é plano.

Restringe, nunca concede. O papel na organização continua sendo o piso: um mantenedor que não seja publicador não publica nada. A restrição por task tira de quem já podia, e não dá a quem não podia. Sem isso, atribuir mantenedor viraria uma forma lateral de conceder acesso, contornando o admin.

Ausência de mantenedor é ausência de restrição, e não “ninguém pode”. O contrário trancaria, na primeira execução da migration, todas as tasks que já existem — e ninguém publicaria nada até atribuir mantenedor a cada uma. Pelo mesmo motivo, remover o último mantenedor devolve a task à organização: uma task cujo único mantenedor saiu da empresa não pode ficar trancada para sempre.

Por nome da task, não por versão. Mantenedor é de uma task ao longo do tempo. Amarrar a uma versão exigiria reatribuir a cada publicação — justo no momento em que a permissão precisa já existir.

A task não precisa existir. Reservar quem cuida de build antes da primeira versão é o caso normal: é assim que um time garante que ninguém publique a v1 no lugar dele.

Custo aceito. A gestão é pessoa a pessoa. Para um time de cinquenta, cansa. Times como agrupamento entram depois — e entram como açúcar sobre isto, sem mudar a semântica: um time se expande nas contas dele no momento da checagem.