Lição 26 · Fusão v3 · @alembic/infra ← índice
Alembic × Hermes — O Curso de Fusão v3 · Fechamento (26–30)

@alembic/infra: DevOps como código, em três planos

Um dos 27 packages do monorepo descreve a própria infraestrutura como DADO tipado: config validada na borda sem dependência nenhuma, logger que por default não faz nada, topologia host | droplet | ci com invariante executável — e um provisionamento que NASCE dry-run e recusa aplicar. É a doutrina fail-closed do motor, aplicada ao chão onde o motor roda.

1

O que o pacote realmente é

O cabeçalho de packages/infra/src/index.ts:1–15 declara a identidade: "DevOps as code for the Alembic engine" — o pacote embarca artefatos REAIS de infraestrutura em diretórios irmãos (terraform/, docker/, caddy/, github/, host/, secrets/, backup/, observability/ — prove com ls packages/infra/) e, por cima, uma superfície de config TIPADA em src/ com seis módulos: result, logger, config, topology, resource, provision.

O detalhe que define o design: o pacote declara ZERO dependências de runtime. Por isso ele NÃO importa o Result de @alembic/contracts — ele carrega um espelho local, byte-compatível na forma (result.ts:1–6: "a tiny, dependency-free Result<T, E> mirroring the narrow waist"), com ok/err/isOk/isErr/toError/tryCatchAsync. E a validação de borda usa um checker próprio minúsculo (reqString, oneOf, regexes) em vez de Zod — mesma doutrina, custo de dependência zero.

Honestidade de mapa: hoje NENHUM outro pacote importa @alembic/infra (prova: grep -rln '@alembic/infra' packages/*/src apps/*/src → vazio). Os consumidores reais são os artefatos irmãos e o wiring futuro marcado no código como TODO(P4, #116) (resource.ts:32, provision.ts:39). O pacote é a PLANTA da casa — a fiação que a lê ainda está na fila.
fusao · 05-ports-and-injection · s1
src/ — a superfície TIPADA (seis módulos, zero deps) result.ts Result local logger.ts NOOP_SINK default config.ts parseInfraConfig topology.ts 3 planos resource.ts plano puro provision.ts dry-run nato diretórios irmãos — os artefatos REAIS que a superfície descreve terraform/ docker/ caddy/ github/ host/ secrets/ backup/ observability/ segredos SEMPRE por NOME de env var — a credencial vive em secrets/.alembic-env (chmod 600), nunca no código
packages/infra: seis módulos tipados descrevem oito diretórios de artefatos; o Result é um espelho local porque o pacote declara zero deps de runtime (index.ts:1–15).

Pense como… a planta elétrica de uma casa: ela nomeia cada disjuntor, cada tomada e cada fase — mas a planta em si não liga NADA. Onde a analogia quebra: aqui até o botão "ligar" (apply: true) existe e responde — recusando educadamente com um err, até que a fiação real seja instalada.

2

Os três planos e a invariante executável

topology.ts modela ONDE cada workload roda como dado: PLANES = ['host', 'droplet', 'ci'] (topology.ts:14). O host é o Mac — hot path: modelos locais, agent-browser, cron launchd (02:00 wiki, 04:00 cortex), backups — e NUNCA exposto à internet. O droplet (DigitalOcean) é a ÚNICA superfície TLS pública, com Caddy terminando :80/:443 na frente de council-api e gateway. O ci (GitHub Actions) constrói, testa e faz deploy — sem estado de longa duração.

O DEFAULT_WORKLOADS (topology.ts:30–66) fixa os seis workloads canônicos nos seus planos — e a doutrina vira FUNÇÃO: findExposureViolations (topology.ts:73–79) devolve os ids de qualquer workload public fora do droplet. Lista vazia = topologia sadia. A regra de segurança não é um comentário: é um filtro que um teste pode rodar.

harness · 0003-substrato-inferencia-delegado · s3
host (Mac) privado · hot path · local-models (Ollama/MLX) · agent-browser · wiki-cron 02:00 / 04:00 nunca exposto à internet droplet (DO) a ÚNICA superfície pública · caddy TLS :80/:443 · council-api (public) · gateway (public) public ⇒ SÓ aqui ci (GitHub Actions) privado · sem estado · build / test / deploy · deploy gated por environment · nenhum estado longo findExposureViolations(workloads) → ids fora da regra (vazio = sadio) — topology.ts:73–79
A doutrina dos 3 planos como dado + função: seis workloads default (topology.ts:30–66) e a invariante "public só no droplet" executável.
3

Config tipada na borda — sem Zod, mesmo assim fail-closed

config.ts define interfaces explícitas por provedor — DigitalOceanConfig, GithubConfig, AgentBrowserConfig, LocalModelsConfig — e enums FECHADOS: 3 tamanhos de droplet (DO_DROPLET_SIZES), 5 regiões (DO_REGIONS), 2 backends locais (ollama | mlx). parseInfraConfig (config.ts:216–228) estreita unknownInfraConfig e nunca lança: cada campo inválido vira err(new Error('infra config: caminho.motivo')) com o caminho exato do problema.

E a regra de segredo é estrutural: config carrega o NOME da env var (apiTokenEnv, tokenEnv…), validado pelo regex ENV_VAR = /^[A-Z][A-Z0-9_]*$/ (config.ts:117) — a credencial em si nunca entra no JSON. Blocos opcionais ausentes caem em defaults tipados (browser alembic/headless/120s; modelos locais em http://127.0.0.1:11434 com keep-alive de 900s — config.ts:98–110).

packages/infra/src/config.ts:216–228 (trecho real)
export const parseInfraConfig = (raw: unknown): Result<InfraConfig, Error> => {
  if (!isRecord(raw)) return fail('<root>', 'must be an object');
  const digitalOcean = parseDigitalOcean(raw.digitalOcean);
  if (!digitalOcean.ok) return digitalOcean;
  const github = parseGithub(raw.github);
  if (!github.ok) return github;
  return ok({
    digitalOcean: digitalOcean.value,
    github: github.value,
    agentBrowser: DEFAULT_AGENT_BROWSER,   // defaults tipados
    localModels: DEFAULT_LOCAL_MODELS,     // p/ blocos opcionais
  });
};
4

Logger inerte + provisionamento que recusa aplicar

logger.ts segue a regra da casa "library code never calls console.log": createLogger emite LogRecords estruturados para um LogSink injetado — e o sink default é NOOP_SINK (logger.ts:36), então IMPORTAR o módulo tem efeito colateral zero. Quem quiser observabilidade real injeta um sink de verdade.

O mesmo espírito, mais radical, em provision.ts: as três entradas de apply — terraformPlan, composeUp, launchdLoad — montam o argv real da ferramenta, mas o helper dryRun (provision.ts:28–45) tem DOIS caminhos: sem apply, loga e devolve ok({ applied: false, argv }); COM apply: true, devolve err('apply path not implemented …'). Ou seja: hoje é IMPOSSÍVEL este pacote mutar infraestrutura — o caminho de mutação está fechado por construção, não por disciplina do operador.

terraformPlan() argv real montado options.apply ? (default: false) false ok({ applied: false, argv }) + log 'infra dry-run' o plano é o produto — $0, zero mutação true err('apply path not implemented …') — TODO(P4, #116); fail-closed por construção
provision.ts:28–45 — o caminho de mutação devolve err até existir; o dry-run com o argv exato é o entregável de hoje.
Você chama composeUp({ apply: true }) hoje. O que acontece?
Correto: dryRun (provision.ts:28–45) só tem dois destinos — sem apply, ok({applied:false, argv}); com apply, err(...) marcado TODO(P4, #116). O pacote descreve e planeja; mutar infraestrutura é um passo futuro explícito, não um efeito escondido.
Por que @alembic/infra tem um Result próprio em vez de importar o de @alembic/contracts?
Correto: result.ts:1–6 explica — o pacote não declara dependência de runtime nenhuma (nem Zod, nem contracts), então carrega um espelho minúsculo do mesmo Result. Call sites leem igual em qualquer pacote; a cintura estreita é a FORMA, não o import.
Camada técnica — comandos copy-paste

Tudo $0 e read-only — verifique cada afirmação desta lição você mesmo:

# a identidade e os seis módulos
sed -n '1,22p' packages/infra/src/index.ts
ls packages/infra/            # terraform/ docker/ caddy/ github/ host/ secrets/ backup/ observability/

# os 3 planos, os 6 workloads e a invariante executável
sed -n '14,80p' packages/infra/src/topology.ts

# a borda de config: regex de env var, enums fechados, parse nunca-lança
grep -n 'ENV_VAR\|DO_DROPLET_SIZES\|DO_REGIONS\|parseInfraConfig' packages/infra/src/config.ts

# o fail-closed do provisionamento (apply → err)
sed -n '28,46p' packages/infra/src/provision.ts

# prova de que ninguém importa o pacote (ainda)
grep -rln '@alembic/infra' packages/*/src apps/*/src
O que levar desta lição
Pergunta de acompanhamento sugerida: "quando o TODO(P4, #116) for implementado, que gate impede um apply acidental?" — repare que a resposta já está desenhada: o default apply: false é o mesmo padrão dupla-trava do marketing. Próxima lição: a disciplina que construiu este curso inteiro — internalizada como pacote: @alembic/loop-engineering.