← Voltar ao blog

Backstage + Crossplane + ArgoCD: do balcão da oficina ao motor montado

Backstage + Crossplane + ArgoCD: do balcão da oficina ao motor montado Backstage + Crossplane + ArgoCD: do balcão da oficina ao motor montado

⚠️ Aviso de fábrica: eu gosto de carros. Muito. Então já peço desculpas adiantadas — este post vai lotado de referências a oficina, preparação de motor e turbo. 🏎️ Se você é mais de mecânica de software do que de mecânica de verdade, relaxa: cada analogia vem com a tradução técnica do lado.

Toda oficina de preparação começa do mesmo jeito: um mecânico bom, uma chave de roda e zero processo. O cliente chega, descreve o que quer (“quero uns 300 cv, mas tem que segurar o dia a dia”), e o mecânico monta tudo na mão — escolhe o turbo, dimensiona os bicos, acerta o mapa. Funciona lindamente… até a fila de clientes crescer. Aí o preparador vira gargalo, cada carro sai diferente do outro, e ninguém lembra qual mapa foi pra qual motor.

Plataforma de infraestrutura é a mesma coisa. O “preparador-faz-tudo” é o time de DevOps respondendo ticket: cria um namespace pra mim, sobe um bucket, libera um banco. Cada pedido é artesanal, cada entrega é levemente diferente, e o conhecimento mora na cabeça de duas pessoas.

As oficinas de elite resolvem isso com três coisas: um catálogo de kits na parede (Stage 1, Stage 2, Stage 3 — o cliente escolhe o kit, não o parafuso), uma linha de montagem que transforma o pedido em motor, e um chefe de oficina obsessivo que confere se o carro que está na rua é exatamente igual ao projeto na bancada.

Em platform engineering, esses três papéis têm nome:

  • Backstage é o balcão com o catálogo de kits.
  • Crossplane é a linha de montagem.
  • ArgoCD é o chefe de oficina.

Este post é uma aula em duas partes. Na Parte 1, monto os três pilares do zero, com um laboratório local que você pode copiar e colar — baby steps mesmo. Na Parte 2, abro o capô do whisperops, um projeto real que usa exatamente essa tríade pra entregar agentes de IA self-service, e disseco três recursos customizados em ordem crescente de complexidade: XDataset, XAgentBudget e XDatasetAgent. No final, você deve sair capaz de montar a sua própria esteira.

Pega o café ☕ (ou o energético de posto) e vem.


Parte 1 — Os três pilares, baby steps

Backstage: o balcão da oficina 🛎️

Backstage é um portal de desenvolvedor open source criado pelo Spotify. Ele faz várias coisas (catálogo de serviços, documentação, plugins), mas para esta aula o que importa é o scaffolder — o mecanismo de Software Templates.

Um Software Template é a ficha de pedido da oficina: um formulário com poucos campos bem escolhidos. O cliente não preenche “diâmetro do comando de válvulas” — ele escolhe “Stage 2” e o resto é derivado. A anatomia de um template tem duas metades:

# template.yaml — a ficha de pedido do kit
apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
  name: garage-stage-kit
  title: Stage Kit
  description: Peça um kit de preparação completo para o seu time.
spec:
  owner: platform-team
  type: service

  # METADE 1 — o formulário. Cada propriedade vira um campo na UI.
  # JSON Schema puro: validação acontece no browser, antes de
  # qualquer coisa tocar o cluster.
  parameters:
    - title: Pedido
      required: [team_name, stage]
      properties:
        team_name:
          title: Nome do time
          type: string
          # regex no schema = pedido inválido nem sai do balcão
          pattern: '^[a-z][a-z0-9-]{2,28}$'
        stage:
          title: Kit
          type: string
          enum: [stage1, stage2, stage3]

  # METADE 2 — os passos. O que o balcão faz quando o cliente assina.
  steps:
    # 1. Renderiza os arquivos do skeleton/ substituindo ${{values.X}}
    - id: fetch
      action: fetch:template
      input:
        url: ./skeleton
        values:
          team_name: ${{ parameters.team_name }}
          stage: ${{ parameters.stage }}

    # 2. Cria um repositório Git e dá push no resultado.
    #    O pedido assinado vai pro caderno de projetos da oficina.
    #    (Gitea = o servidor Git local que vamos subir no laboratório)
    - id: publish
      action: publish:gitea
      input:
        repoUrl: cnoe.localtest.me:8443/gitea?repo=garage-${{ parameters.team_name }}
        defaultBranch: main

    # 3. Registra a Application do ArgoCD apontando pro repo novo.
    #    Action customizada que o Backstage do CNOE traz embutida —
    #    faz via API o kubectl apply que faremos na mão no laboratório.
    - id: argocd
      action: cnoe:create-argocd-app
      input:
        appName: garage-${{ parameters.team_name }}
        appNamespace: argocd
        argoInstance: in-cluster
        projectName: default
        repoUrl: https://cnoe.localtest.me:8443/gitea/giteaAdmin/garage-${{ parameters.team_name }}
        path: manifests

Repara em dois detalhes que vão voltar na Parte 2:

  1. O template não cria os recursos do pedido. Ele escreve o pedido num repositório Git e, no máximo, registra a Application que diz ao ArgoCD pra olhar esse repo. Quem aplica o conteúdo é outra peça (spoiler: o chefe de oficina).
  2. A sintaxe é ${{ values.x }} — com o $ na frente. Backstage usa Nunjucks por baixo, mas com esse prefixo próprio. Esquecer o $ faz a expressão passar crua pro Git, e quem explode é o ArgoCD na hora do apply, com um erro críptico de invalid map key. Anota essa.

Crossplane: a linha de montagem 🏭

Crossplane transforma o Kubernetes num control plane universal: além de Pods e Services, o cluster passa a saber criar buckets GCS, service accounts, bancos — qualquer recurso que tenha um provider. Mas o superpoder não é falar com a nuvem; é a abstração em camadas. Três conceitos:

ConceitoNa oficinaO que é
XRD (CompositeResourceDefinition)A homologação da ficha de pedidoDefine a API do seu recurso composto: quais campos o pedido aceita, quais são obrigatórios, qual o regex de cada um
CompositionO manual de montagem do kitDiz COMO expandir um pedido em N recursos reais
XR (Composite Resource)Um pedido específico”Stage 2 pro time ae86” — uma instância da API que a XRD definiu

E embaixo de tudo, os Managed Resources (MRs) — as peças individuais (um bucket, uma IAM binding, um Deployment) que os providers reconciliam.

A parte que mudou de patamar nas versões recentes: a Composition moderna roda em modo Pipeline, uma sequência de Composition Functions — cada função é uma estação da linha de montagem. A primeira estação mede o pedido, a segunda usina as peças, a terceira monta, a última faz o dyno e carimba “pronto”. Functions podem ser genéricas de prateleira (function-go-templating, function-auto-ready) ou suas, escritas em Python, Go ou KCL.

Um exemplo mínimo — a XRD primeiro:

# xrd.yaml — a homologação: que campos um pedido XGarage aceita
apiVersion: apiextensions.crossplane.io/v1
kind: CompositeResourceDefinition
metadata:
  # convenção: <plural>.<grupo>
  name: xgarages.blog.opsbogus.dev
spec:
  # Cluster-scoped: o XR vive fora de namespaces (ele vai CRIAR um).
  # Crossplane v2 também suporta XRs namespaced — falo disso na Parte 2.
  scope: Cluster
  group: blog.opsbogus.dev
  names:
    kind: XGarage
    plural: xgarages
  # qual Composition usar quando o pedido não especificar
  defaultCompositionRef:
    name: xgarage-default
  versions:
    - name: v1alpha1
      served: true
      referenceable: true
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              properties:
                teamName:
                  type: string
                  pattern: '^[a-z][a-z0-9-]{2,28}$'
                stage:
                  type: string
                  enum: [stage1, stage2, stage3]
              required: [teamName, stage]

E a Composition, em modo Pipeline com duas estações:

# composition.yaml — o manual de montagem do kit XGarage
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
  name: xgarage-default
spec:
  # amarra este manual à API que a XRD homologou
  compositeTypeRef:
    apiVersion: blog.opsbogus.dev/v1alpha1
    kind: XGarage
  mode: Pipeline
  pipeline:
    # ESTAÇÃO 1: renderiza os recursos desejados a partir do pedido.
    # function-go-templating é a function "genérica de prateleira":
    # Go templates lendo o XR observado.
    - step: render
      functionRef:
        name: function-go-templating
      input:
        apiVersion: gotemplating.fn.crossplane.io/v1beta1
        kind: GoTemplate
        source: Inline
        inline:
          template: |
            {{- $team := .observed.composite.resource.spec.teamName }}
            {{- $stage := .observed.composite.resource.spec.stage }}
            # Peça 1: o box do time (um Namespace).
            # Object é o MR do provider-kubernetes: um "envelope" que
            # aplica qualquer manifesto K8s como recurso gerenciado.
            apiVersion: kubernetes.crossplane.io/v1alpha2
            kind: Object
            metadata:
              # nome explícito = previsível no kubectl (sem sufixo aleatório)
              name: garage-{{ $team }}-namespace
              annotations:
                # nome lógico da peça dentro da Composition
                gotemplating.fn.crossplane.io/composition-resource-name: namespace
            spec:
              forProvider:
                manifest:
                  apiVersion: v1
                  kind: Namespace
                  metadata:
                    name: garage-{{ $team }}
              # diz COMO o provider autentica — explico no laboratório
              providerConfigRef:
                name: in-cluster
            ---
            # Peça 2: a ficha técnica colada na parede do box (ConfigMap).
            apiVersion: kubernetes.crossplane.io/v1alpha2
            kind: Object
            metadata:
              name: garage-{{ $team }}-spec-sheet
              annotations:
                gotemplating.fn.crossplane.io/composition-resource-name: spec-sheet
            spec:
              forProvider:
                manifest:
                  apiVersion: v1
                  kind: ConfigMap
                  metadata:
                    name: spec-sheet
                    namespace: garage-{{ $team }}
                  data:
                    stage: {{ $stage }}
                    team: {{ $team }}
              providerConfigRef:
                name: in-cluster

    # ESTAÇÃO FINAL: o dyno. function-auto-ready marca o XR como Ready
    # quando todas as peças compostas ficarem Ready. SEMPRE a última.
    - step: ready
      functionRef:
        name: function-auto-ready

O pedido em si — repara como ele é ridiculamente pequeno comparado ao que gera:

# xr.yaml — o pedido: "Stage 2 pro time ae86"
apiVersion: blog.opsbogus.dev/v1alpha1
kind: XGarage
metadata:
  name: projeto-ae86
spec:
  teamName: ae86
  stage: stage2

Essa assimetria é o coração do padrão: a interface é magra, a expansão é gorda. O cliente assina uma linha; a linha de montagem entrega o motor completo. E como a expansão acontece dentro do cluster, no reconcile do Crossplane, ela se mantém: se alguém deletar o ConfigMap na mão, o Crossplane recria. É um motor que se remonta sozinho.

ArgoCD: o chefe de oficina 🧐

ArgoCD implementa GitOps: o Git é a única fonte de verdade, e um controller compara continuamente o que está declarado no repositório com o que está rodando no cluster. Detectou diferença? Corrige. Na oficina: o chefe anda com o projeto debaixo do braço e não aceita “gambiarra de estacionamento” — se o carro na rua diverge do projeto no caderno, ele desfaz a gambiarra (selfHeal) ou remove a peça que não consta no projeto (prune).

A unidade de trabalho é a Application: este repositório, neste path, aplicado neste cluster.

# application.yaml — o chefe de oficina assume o projeto garage-ae86
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: garage-ae86
  namespace: argocd
spec:
  project: default
  source:
    # URL in-cluster do Gitea (ArgoCD roda dentro do cluster,
    # então usa o DNS de service, não o hostname externo)
    repoURL: http://my-gitea-http.gitea.svc.cluster.local:3000/giteaAdmin/garage-ae86.git
    targetRevision: HEAD
    # só esta pasta é aplicada — arquivos fora dela são invisíveis
    path: manifests
  destination:
    server: https://kubernetes.default.svc
  syncPolicy:
    automated:
      prune: true     # peça fora do projeto? remove
      selfHeal: true  # gambiarra no carro? desfaz

Dois padrões do ArgoCD que aparecem em toda plataforma séria:

  • App-of-apps: uma Application “raiz” cujo conteúdo são… outras Applications. Você aplica UMA vez na mão, e ela puxa o resto da plataforma. É o padrão oficial de bootstrap.
  • Sync waves: anotações argocd.argoproj.io/sync-wave: "3" que ordenam o apply dentro de um sync. Não dá pra apertar o cabeçote antes de assentar o bloco — e não dá pra aplicar um ProviderConfig antes do CRD dele existir. Docs aqui.

O triângulo: quem escreve, quem entrega, quem monta 🔺

Agora a mágica — como os três se conectam. A resposta curta: eles nunca se falam diretamente. Conversam por intermédio do Git e do API server.

DEV o cliente 1. preenche BACKSTAGE o balcão · escreve GIT (Gitea) o caderno 2. git push (1 arquivo) 3. cria Application (API) 4. sync ArgoCD o chefe · entrega 5. kubectl apply (o XR) CLUSTER Crossplane a linha · monta 6. expande (reconcile) N recursos reais Namespaces · RBAC · Deployments · buckets · IAM…

A divisão de responsabilidades:

  1. Backstage escreve. O scaffolder renderiza o pedido (um XR) e dá push no Git. Ele não tem credencial pra criar bucket nenhum — e isso é uma feature: a superfície de ataque do portal é “escrever YAML num repo”.
  2. ArgoCD entrega. Ele leva o pedido do caderno pro cluster e garante que continue lá, idêntico, pra sempre. Ele também não sabe o que é um bucket — pra ele, o XR é um YAML como outro qualquer.
  3. Crossplane monta. Ele pega o XR e expande nos N recursos reais, com reconcile contínuo. Ele não sabe que existe um portal nem um repo Git.

Cada ferramenta tem UM trabalho. Você pode trocar o Backstage por outro portal (ou por um git push na mão — vamos fazer isso já já) sem tocar no resto. Pode trocar o Gitea por GitHub. Pode adicionar políticas com Kyverno no meio sem nenhuma das três saber. Essa combinação tem até nome de stack de referência na comunidade: BACK stack (Backstage, ArgoCD, Crossplane, Kyverno).

Por que não deixar o Backstage criar os recursos direto via API? Porque aí o pedido não fica registrado em lugar nenhum — sem trilha de auditoria, sem rollback via git revert, sem reconcile contínuo. O Git no meio do caminho é o que transforma “um script que cria coisas” em “uma plataforma que mantém coisas”.

Mão na massa: o laboratório local 🔧

Hora de sujar as mãos. Vamos montar o triângulo completo na sua máquina usando idpbuilder, a ferramenta da comunidade CNOE que sobe um IDP local com um comando: um cluster kind com Gitea + ArgoCD + ingress, tudo conversando entre si, certificados e DNS resolvidos (o domínio cnoe.localtest.me aponta pra 127.0.0.1).

Pré-requisitos: Docker rodando, kubectl, helm, git e curl.

Passo 1 — instale o idpbuilder e crie o IDP:

A instalação é um binário único: baixa, extrai, roda. Sem mágica.

# macOS Apple Silicon — pra Linux/Intel troque "darwin-arm64" por
# "linux-amd64" (ou o seu par OS-arch; veja a página de releases)
curl -fsSL -o idpbuilder.tar.gz \
  https://github.com/cnoe-io/idpbuilder/releases/latest/download/idpbuilder-darwin-arm64.tar.gz
tar xzf idpbuilder.tar.gz idpbuilder
./idpbuilder version

# opcional: põe no PATH — daqui em diante chamo só `idpbuilder`
# (se pular esta linha, use ./idpbuilder nos próximos comandos)
sudo install -m 0755 idpbuilder /usr/local/bin/
# sobe o IDP: cluster kind + Gitea + ArgoCD + nginx ingress (~2 min).
# --use-path-routing serve tudo sob UM hostname (cnoe.localtest.me:8443
# /gitea, /argocd…) — o mesmo modo que o pacote do Backstage do Passo 7
# usa, então as URLs não mudam no meio do laboratório.
idpbuilder create --use-path-routing

# credenciais dos serviços
idpbuilder get secrets
# ArgoCD UI: https://cnoe.localtest.me:8443/argocd
# Gitea UI:  https://cnoe.localtest.me:8443/gitea

Passo 2 — instale o Crossplane:

helm repo add crossplane-stable https://charts.crossplane.io/stable
helm repo update
# --wait segura até o control plane estar de pé
helm install crossplane crossplane-stable/crossplane \
  --namespace crossplane-system --create-namespace --wait

Passo 3 — instale o provider e as functions:

O provider-kubernetes é o provider que aplica manifests K8s arbitrários como Managed Resources — perfeito pro laboratório porque não precisa de credencial de nuvem nenhuma. Atenção à ordem dentro do arquivo: o DeploymentRuntimeConfig vem antes do Provider que o referencia.

# provider-and-functions.yaml
# 1º: fixa o nome do ServiceAccount do provider. Sem isso, o Crossplane
# gera o SA com sufixo de hash (provider-kubernetes-abc123) e o
# ClusterRoleBinding estático do próximo bloco não casa até você
# atualizar o Provider. Nome estável = RBAC estável.
apiVersion: pkg.crossplane.io/v1beta1
kind: DeploymentRuntimeConfig
metadata:
  name: provider-kubernetes-runtime
spec:
  serviceAccountTemplate:
    metadata:
      name: provider-kubernetes
---
# 2º: o Provider é um PACOTE: o Crossplane baixa a imagem, instala os
# CRDs (Object, ProviderConfig) e sobe o pod do controller. Isso leva
# ~1 min — guarda essa latência na memória, ela vira gotcha na Parte 2.
apiVersion: pkg.crossplane.io/v1
kind: Provider
metadata:
  name: provider-kubernetes
spec:
  package: xpkg.upbound.io/crossplane-contrib/provider-kubernetes:v1.2.1
  runtimeConfigRef:
    name: provider-kubernetes-runtime   # ← o SA de nome fixo acima
---
# As duas functions de prateleira que a Composition usa.
apiVersion: pkg.crossplane.io/v1
kind: Function
metadata:
  name: function-go-templating
spec:
  package: xpkg.upbound.io/crossplane-contrib/function-go-templating:v0.11.0
---
apiVersion: pkg.crossplane.io/v1
kind: Function
metadata:
  name: function-auto-ready
spec:
  package: xpkg.upbound.io/crossplane-contrib/function-auto-ready:v0.6.4
kubectl apply -f provider-and-functions.yaml

# espere tudo ficar INSTALLED=True HEALTHY=True
kubectl get providers.pkg.crossplane.io,functions.pkg.crossplane.io

Agora o provider precisa de duas coisas: permissão (RBAC) pra criar recursos no cluster, e um ProviderConfig dizendo como autenticar.

# provider-rbac-and-config.yaml
# O pod do provider roda com o ServiceAccount que fixamos acima;
# damos cluster-admin porque é um laboratório single-tenant. Em
# produção, restrinja a um ClusterRole com exatamente os kinds que
# suas Compositions emitem.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: provider-kubernetes-cluster-admin
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: cluster-admin
subjects:
  - kind: ServiceAccount
    # este nome só é estável porque o DeploymentRuntimeConfig o fixou
    name: provider-kubernetes
    namespace: crossplane-system
---
# "InjectedIdentity" = usa o próprio ServiceAccount do pod do provider
# pra falar com o API server. O padrão pra atuar no MESMO cluster.
apiVersion: kubernetes.crossplane.io/v1alpha1
kind: ProviderConfig
metadata:
  name: in-cluster
spec:
  credentials:
    source: InjectedIdentity
kubectl apply -f provider-rbac-and-config.yaml

(O whisperops usa exatamente esse trio, com o RuntimeConfig numa sync wave anterior à do Provider — a mesma lição de ordenação vai voltar na Parte 2.)

Passo 4 — aplique a XRD e a Composition (os YAMLs xrd.yaml e composition.yaml da seção anterior):

kubectl apply -f xrd.yaml
kubectl apply -f composition.yaml

# a XRD precisa ficar ESTABLISHED=True — é o momento em que o
# Kubernetes passa a aceitar pedidos do kind XGarage
kubectl get xrd
# NAME                         ESTABLISHED   OFFERED   AGE
# xgarages.blog.opsbogus.dev   True                    15s

kubectl get compositions
# NAME              XR-KIND   XR-APIVERSION                 AGE
# xgarage-default   XGarage   blog.opsbogus.dev/v1alpha1    10s

Passo 5 — primeiro teste, sem Git ainda. Aplique o pedido direto e veja a linha de montagem trabalhar:

kubectl apply -f xr.yaml    # o XGarage "projeto-ae86"

# o pedido
kubectl get xgarage
# NAME           SYNCED   READY   COMPOSITION       AGE
# projeto-ae86   True     True    xgarage-default   30s

# as peças que ele expandiu
kubectl get objects.kubernetes.crossplane.io
# NAME                     KIND        PROVIDERCONFIG   SYNCED   READY   AGE
# garage-ae86-namespace    Namespace   in-cluster       True     True    40s
# garage-ae86-spec-sheet   ConfigMap   in-cluster       True     True    40s

kubectl get namespace garage-ae86
kubectl get configmap -n garage-ae86 spec-sheet -o yaml

Agora o teste que vende o conceito — tente fazer uma gambiarra:

# deleta o ConfigMap na mão (a "gambiarra de estacionamento")
kubectl delete configmap -n garage-ae86 spec-sheet

Quanto tempo até voltar? Aqui mora uma nuance que vale aprender cedo: o provider não assiste a peça embrulhada — ele detecta o drift no próximo poll do Object MR, e o default do provider-kubernetes é 10 minutos (o reconcile do XR em si roda a cada ~60s, mas ele só garante que o MR existe com o spec certo; quem percebe o ConfigMap sumido é o poll do provider). Pra não esperar, force um reconcile tocando o MR — qualquer update no MR enfileira ele na hora:

kubectl annotate objects.kubernetes.crossplane.io garage-ae86-spec-sheet \
  reconcile.crossplane.io/now="$(date +%s)" --overwrite

kubectl get configmap -n garage-ae86 spec-sheet
# ele voltou 🪄 — o reconcile remontou a peça.

Passo 6 — o loop GitOps completo. Agora vamos fazer NA MÃO o que o Backstage faria — porque o Backstage não faz mágica, faz exatamente isto:

# credencial do Gitea local — ATENÇÃO: a senha gerada vem CHEIA de
# caracteres especiais que o shell interpreta ([, }, ?, *, $…).
# Cole sempre entre ASPAS SIMPLES, senão o zsh explode com
# "bad pattern" / "no matches found".
idpbuilder get secrets -p gitea   # usuário: giteaAdmin
GITEA_PASS='<COLE-A-SENHA-AQUI>'

# e pra embutir na URL do remote, ela precisa de URL-encoding
# (um "?" cru no meio da senha quebra o parse da URL — o git acha
# que a porta não é um número):
GITEA_PASS_ENC=$(printf '%s' "$GITEA_PASS" | \
  python3 -c "import sys,urllib.parse; print(urllib.parse.quote(sys.stdin.read(), safe=''))")

# 1. cria o repositório via API (o que o publish:gitea faria)
#    (no -u a senha vai crua — aspas duplas bastam, curl não parseia URL aqui)
curl -k -X POST "https://cnoe.localtest.me:8443/gitea/api/v1/user/repos" \
  -u "giteaAdmin:${GITEA_PASS}" -H "Content-Type: application/json" \
  -d '{"name": "garage-ae86", "default_branch": "main"}'

# 2. monta o conteúdo: o pedido dentro de manifests/
mkdir -p garage-ae86/manifests && cd garage-ae86
cp ../xr.yaml manifests/xgarage.yaml
git init -b main && git add . && git commit -m "pedido: stage2 no ae86"

# 3. push (cert self-signed → sslVerify=false; senha ENCODADA na URL)
git -c http.sslVerify=false remote add origin \
  "https://giteaAdmin:${GITEA_PASS_ENC}@cnoe.localtest.me:8443/gitea/giteaAdmin/garage-ae86.git"
git -c http.sslVerify=false push -u origin main
# (errou a URL na primeira tentativa e o git reclama "remote origin
#  already exists"? Corrija com: git remote set-url origin "<URL-certa>")

# 4. registra a Application (o que o cnoe:create-argocd-app faria)
kubectl apply -f application.yaml

O XR do Passo 5 já existe no cluster — o ArgoCD simplesmente o adota, porque o conteúdo no Git é idêntico ao que está rodando. A partir de agora, quem manda é o caderno.

Pronto: o triângulo está fechado. Agora edite o pedido no Git — troque stage: stage2 por stage: stage3, commit, push — e observe o ArgoCD sincronizar e o Crossplane atualizar o ConfigMap. Você nunca mais tocou o cluster; só o caderno de projetos.

# acompanhe a propagação
kubectl get application -n argocd garage-ae86 -w
kubectl get configmap -n garage-ae86 spec-sheet -o jsonpath='{.data.stage}'

Passo 7 (opcional) — o Backstage de verdade. O idpbuilder instala o portal completo (Backstage + Keycloak + Argo Workflows) via pacote de referência da comunidade CNOE. Uma ressalva honesta: pacotes só registram na criação do cluster, então o comando recria o kind do zero (~6 min) — o estado dos passos 2–6 evapora. A boa notícia: como já estávamos em path-routing desde o Passo 1, todas as URLs continuam as mesmas, e reaplicar os passos 2–4 é copy-paste dos mesmos arquivos. Bons motores remontam rápido.

idpbuilder create --recreate --use-path-routing \
  -p https://github.com/cnoe-io/stacks//ref-implementation

Daqui em diante é passo a passo no portal:

7.1 — Login. Abra https://cnoe.localtest.me:8443/ e clique em Sign In. O Backstage delega a autenticação pro Keycloak: usuário user1, senha no campo USER_PASSWORD da saída de idpbuilder get secrets.

7.2 — Registre o template. Antes, suba template.yaml + a pasta skeleton/ num repositório do Gitea — mesmo fluxo de API + push do Passo 6, num repo chamado garage-template. Só falta uma peça que ainda não mostrei: o skeleton, que é literalmente o xr.yaml com placeholders no lugar dos literais:

# skeleton/manifests/xgarage.yaml — o que o fetch:template renderiza.
# Por padrão, o fetch:template processa TODOS os arquivos do skeleton
# como templates, substituindo ${{ values.x }} pelos inputs do form.
apiVersion: blog.opsbogus.dev/v1alpha1
kind: XGarage
metadata:
  name: garage-${{ values.team_name }}
spec:
  teamName: ${{ values.team_name }}
  stage: ${{ values.stage }}

No Backstage: Create… → Register Existing Component, apontando pra URL raw do template: https://cnoe.localtest.me:8443/gitea/giteaAdmin/garage-template/raw/branch/main/template.yaml

7.3 — Peça o kit. Create… de novo: o card Stage Kit agora aparece ao lado dos templates de exemplo do CNOE. Preencha o team_name e escolha o stage — repare que o regex do nome é validado em tempo real, no browser, antes de qualquer coisa tocar o cluster. O pedido inválido nem sai do balcão.

7.4 — Acompanhe a execução. Ao clicar em Create, o scaffolder roda os steps na ordem do template — fetch:template → publish:gitea → cnoe:create-argocd-app — logando cada um em tempo real.

7.5 — Confira o triângulo. O repo novo está no Gitea (/gitea/giteaAdmin/garage-<team>), a Application no ArgoCD (/argocd/applications) e o XR rodando no cluster (kubectl get xgarage). O fluxo é idêntico ao que você fez na mão nos Passos 5–6 — e é por isso que fizemos na mão primeiro.

Dica de debug: crossplane beta trace xgarage projeto-ae86 mostra a árvore completa do XR com o estado de cada peça — o equivalente a abrir o capô com o motor rodando. (O CLI instala com brew install crossplane ou pelo script oficial em docs.crossplane.io.)

Fim da Parte 1. Você tem o triângulo funcionando localmente. Agora vamos ver o que acontece quando esse padrão encontra um problema de verdade.


Parte 2 — whisperops: a oficina de verdade

O whisperops é a minha bancada de testes para esses conceitos: uma plataforma na GCP onde qualquer pessoa cria, pelo Backstage, um agente de IA que analisa um CSV — dois agentes LLM (planner + worker, orquestrados pelo kagent, rodando Gemini via Vertex AI), um sandbox Python pra executar análises, um chat web, budget enforcement e observabilidade completa. Tudo numa única VM rodando kind, com a camada IDP inteira (Gitea, Keycloak, ArgoCD, Backstage) trazida pelo mesmo idpbuilder do laboratório da Parte 1.

GCP VM — cluster kind Camada IDP — CNOE/idpbuilder Gitea · Keycloak · ArgoCD · Backstage Camada Plataforma Crossplane · providers · Kyverno · kagent LGTM · Reflector · dataset-watcher Camada Agente — 1 ns por agente chat-frontend · planner · worker · sandbox XAgentBudget (a wastegate do Nível 2) chamada externa Vertex AI Gemini 2.5 Pro

O que interessa pra esta aula é o miolo: três recursos customizados em ordem crescente de complexidade. Vou apresentá-los como três níveis de preparação:

  1. XDataset — o catálogo de combustíveis homologados (nem é Crossplane!)
  2. XAgentBudget — a wastegate eletrônica com corte (pipeline de 3 funções)
  3. XDatasetAgent — o kit Stage 3 completo (pipeline de 6 funções, 22 peças)

Nível 1 — XDataset: o catálogo de combustíveis homologados ⛽

Todo agente analisa um dataset, e os datasets moram num bucket GCS. A pergunta de design: como o formulário do Backstage sabe quais datasets existem? E como a Composition valida que o dataset pedido é real?

A resposta do whisperops é um registry: um recurso XDataset por CSV no bucket. E aqui vem a primeira lição, que parece pegadinha:

XDataset NÃO é uma XRD do Crossplane. É um CRD comum do Kubernetes.

# xdataset-xrd.yaml (o nome do arquivo engana — leia o comentário)
# XDataset não compõe nada — é um registro de dados puro, gerenciado
# pelo controller dataset-watcher, que é dono do status e das
# transições de ready. Uma XRD exigiria uma Composition dona do
# reconcile, o que CONFLITARIA com o controller escrevendo status.
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: xdatasets.whisperops.io
spec:
  group: whisperops.io
  scope: Cluster
  names:
    kind: XDataset
    plural: xdatasets
    shortNames: [xds, datasets]
  versions:
    - name: v1alpha1
      served: true
      storage: true
      subresources:
        status: {}            # status separado: só o controller escreve
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              properties:
                gcsPath:
                  type: string
                  pattern: "^gs://.+\\.csv$"   # validação na admissão
                displayName:
                  type: string
                sizeBytes:
                  type: integer
                  minimum: 1
              required: [gcsPath, displayName, sizeBytes]
            status:
              type: object
              properties:
                ready: { type: boolean }
                sizeHuman: { type: string }
                lastSeen: { type: string, format: date-time }

A lição: nem tudo precisa ser XR. Se o recurso não compõe nada — se ele é só um registro com dono claro — um CRD comum com um controller é a ferramenta certa. Forçar uma XRD aqui criaria um conflito de propriedade sobre o status.

Quem mantém o catálogo é o dataset-watcher, um controller Python (~430 linhas no módulo principal) que roda um reconcile a cada 30 segundos — o almoxarife robô da oficina, conferindo o estoque de combustível:

  1. Lista os *.csv do bucket gs://whisperops-datasets/.
  2. Para cada blob, faz upsert de um XDataset (normalizando o nome: Athlete_Recovery.csv vira athlete-recovery, mas o spec.gcsPath preserva o nome original do blob).
  3. Publica em paralelo uma entity Resource do Backstage num repositório Git de catálogo — é daqui que o dropdown do formulário se alimenta.
  4. Se o CSV some do bucket, o CR e a entity somem juntos.

O ponto 3 fecha um circuito elegante. O formulário do Backstage não usa um enum hardcoded; usa um EntityPicker filtrando entities do catálogo:

# trecho do template dataset-whisperer — o dropdown dinâmico
dataset_ref:
  title: Dataset
  type: string
  ui:field: EntityPicker          # dropdown alimentado pelo catálogo
  ui:options:
    catalogFilter:
      kind: Resource
      spec.type: dataset           # só entities publicadas pelo watcher

Resultado: make upload-datasets sobe um CSV novo, e em ~1 minuto ele aparece no dropdown do formulário — zero mudança de código ou template. Um combustível novo chegou na oficina, o almoxarife etiquetou, e a ficha de pedido já oferece a opção.

O catálogo em funcionamento — kubectl get xds lê como a prateleira etiquetada da oficina (as printer columns vêm do próprio CRD):

kubectl get xds
NAME                 SIZE       READY   LAST_SEEN   AGE
california-housing   1.9 MiB    true    21s         3d2h
online-retail-ii     90.4 MiB   true    21s         3d2h
spotify-tracks       19.2 MiB   true    21s         3d2h

E quem o registro? A Composition do agente, na primeira estação — usando um mecanismo do Crossplane chamado extra resources: a function declara “eu preciso do XDataset chamado california-housing” e o Crossplane busca pra ela. Detalhe importante: como o mecanismo busca qualquer recurso por grupo+kind+nome, funciona com CRD comum — mais um motivo pra não forçar XRD no registry. Veremos o código dessa leitura no Nível 3.

A arquitetura completa do registry, em um diagrama:

gs://whisperops-datasets/ o estoque físico (a fonte) lista a cada 30s dataset-watcher o almoxarife upsert publica entities XDataset CRs o catálogo K8s Backstage catalog o catálogo do balcão ↑ lido pela Composition ↑ lido pelo EntityPicker

Uma fonte de verdade (o bucket), três projeções (CR, entity, contexto da pipeline) — cada consumidor lê a projeção que entende, e nenhum deles precisa de credencial do GCS além do próprio watcher.

🙃 Confissão de garagem: aqui eu me empolguei, admito. Um simples botão de upload no Backstage resolveria o problema — mas eu queria explorar o padrão registry, escrever um controller customizado, ver o EntityPicker alimentado dinamicamente… Às vezes a gente compra o comando de válvulas forjado pra um motor que nem precisava, só pra ver como assenta. O lado bom: você acabou de ganhar o tour completo pelo padrão.

Nível 2 — XAgentBudget: a wastegate eletrônica 💸

Agente LLM gasta dinheiro a cada token. Sem controle, um agente em loop é um motor com a wastegate travada: a pressão sobe até estourar — só que aqui o que estoura é a fatura. O XAgentBudget é a wastegate eletrônica com corte: mede a pressão continuamente e, se passar do limite, corta o combustível.

Antes do “como”, o “porquê” da arquitetura. A primeira versão era um controller imperativo de 470 linhas — e o bug clássico apareceu: três componentes diferentes (o controller, um probe no frontend e um classificador de erro) inferiam de forma independente “esse agente está pausado?”, e divergiam. Banner falso de “Budget Exhausted” toda vez que o planner espirrava. A reescrita como Composition resolve isso de raiz: o status do XR vira a única fonte de verdade, escrita por um pipeline e lida por todo mundo.

A XRD (resumida) mostra o contrato:

# xrd.yaml do XAgentBudget — repare no scope
apiVersion: apiextensions.crossplane.io/v1
kind: CompositeResourceDefinition
metadata:
  name: xagentbudgets.whisperops.io
spec:
  scope: Namespaced          # o XR vive DENTRO do namespace do agente
  group: whisperops.io
  names:
    kind: XAgentBudget
    shortNames: [xab, budget]
  versions:
    - name: v1alpha1
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              properties:
                agentName:  { type: string }
                budgetUsd:  { type: number, minimum: 0, maximum: 10000 }
                pricingRef:                  # tabela de preço por token
                  type: object               # (ConfigMap externo — trocar
                  properties:                # preço não exige rebuild)
                    name: { type: string }
                    namespace: { type: string }
                enforcement:
                  type: string
                  enum: [enabled, monitor-only]   # modo bancada: mede, não corta
                  default: enabled
            status:
              type: object
              properties:
                spentUsd: { type: number }
                ratio:    { type: number }       # gasto / budget
                paused:   { type: boolean }      # A fonte de verdade
                cause:
                  type: string
                  enum: [running, budget-exhausted, agent-unreachable, unknown]

A Composition é um pipeline de 3 estações + dyno, rodando a cada ~60 segundos (o poll interval default do Crossplane — mais sobre isso adiante):

a cada ~60s, o reconcile roda a linha fetch-spend mede a pressão · Mimir ctx: spend_usd decide compara c/ limite · grava status ctx: should_pause render atua: replicas 0 ou 1 decide → XR.status.paused (fonte de verdade) render → Object MRs patcham planner + worker

Estação 1 — fetch-spend (Python, com o function-sdk-python): consulta o Mimir (Prometheus) somando os contadores de tokens do agente e multiplicando pela tabela de preço:

# function-budget-fetch-spend — o manômetro
# Para cada tipo de token (input/output/cached), soma o increase()
# na janela de vida do XR e converte em USD pela tabela de preço
# (ConfigMap montado em /etc/pricing — trocar preço é só config).
for metric, price_key in (
    ("whisperops_tokens_input_total", "input_per_million"),
    ("whisperops_tokens_output_total", "output_per_million"),
    ("whisperops_tokens_cached_input_total", "cached_input_per_million"),
):
    unit_price = prices[price_key] / 1_000_000
    # o matcher de model importa: a tabela de preço é por modelo
    expr = (
        f'sum(increase({metric}{{agent_name="{agent}",model="{MODEL_NAME}"}}'
        f"[{window_s}s])) * {unit_price}"
    )
    ...

# Decisão de design: FALHA ABERTA. Se o Mimir cair, gasto = 0.0 e o
# agente segue rodando — uma queda de observabilidade nunca pode
# pausar um agente saudável. O preço disso: sub-enforcement
# silencioso enquanto o Mimir estiver fora.
except httpx.HTTPError as e:
    response.warning(rsp, f"mimir query failed: {e}; using spend=0.0")
    spend = 0.0

# O resultado vai pro CONTEXTO da pipeline — não pro status.
# Contexto = bilhete que passa de estação em estação, morre no
# fim do reconcile. Status = o que fica gravado no XR.
rsp.context["spend_usd"] = spend
rsp.context["window_sec"] = window_s

Estação 2 — decide: a lógica pura. Compara, decide, e grava o status — o único lugar onde a verdade é escrita:

# function-budget-decide — a ECU
ratio = spend / budget if budget > 0 else 0.0

if deletion_ts:                      # XR sendo deletado? despausa
    should_pause = False             # (devolve o carro funcionando)
elif enforcement == "monitor-only":  # modo bancada: mede, não corta
    should_pause = False
else:
    should_pause = ratio >= 1.0      # 100% do budget = corte

# grava a fonte de verdade no status do XR
resource.update(rsp.desired.composite, {
    "status": {
        "spentUsd": round(spend, 4),
        "ratio": round(ratio, 4),
        "ratioPct": f"{ratio * 100:.2f}%",   # p/ kubectl get xab
        "paused": should_pause,
        "cause": "budget-exhausted" if should_pause else "running",
    },
})
# e passa a decisão pra próxima estação via contexto
rsp.context["should_pause"] = should_pause

Estação 3 — render: a atuação. Emite dois Object MRs do provider-kubernetes que patcham spec.replicas dos Deployments do planner e do worker:

# function-budget-render — o corte de combustível
def _make_object_mr(role: str, namespace: str, replicas: int) -> dict:
    return {
        # flavor NAMESPACED do Object (.m.) — obrigatório porque o
        # XAgentBudget é um XR namespaced, e no Crossplane v2 um XR
        # namespaced só compõe MRs namespaced. O Object legado
        # (kubernetes.crossplane.io/v1alpha2) é cluster-scoped e
        # falha com "cannot apply cluster scoped composed resource".
        "apiVersion": "kubernetes.m.crossplane.io/v1alpha1",
        "kind": "Object",
        "metadata": {"name": f"{namespace}-{role}-replicas",
                     "namespace": namespace},
        "spec": {
            # Observe + Update, SEM Create e SEM Delete:
            # - não cria: o Deployment já existe (o kagent o criou)
            # - não deleta: GC do MR não pode derrubar o Deployment
            "managementPolicies": ["Observe", "Update"],
            "forProvider": {
                # manifesto ESPARSO de propósito: só o campo que
                # queremos possuir. Server-side apply faz o provider
                # ser dono de spec.replicas e de NADA mais — um
                # manifesto completo aqui roubaria a posse de todos
                # os campos do operador kagent.
                "manifest": {
                    "apiVersion": "apps/v1",
                    "kind": "Deployment",
                    "metadata": {"name": role, "namespace": namespace},
                    "spec": {"replicas": replicas},
                },
            },
            "providerConfigRef": {"kind": "ClusterProviderConfig",
                                  "name": "in-cluster"},
        },
    }

E quem consome a fonte de verdade? O chat-frontend faz um GET do XR a cada request (sem cache, de propósito — despausar fica visível em um ciclo) e mapeia para HTTP com semântica honesta: 402 Payment Required só quando paused && cause == "budget-exhausted"; 503 é reservado pra falha de infraestrutura. Foi exatamente essa separação que matou o banner falso.

As nuances que valem a aula:

  • Os 60 segundos de latência não estão configurados em lugar nenhum — é o poll interval default do Crossplane. Um agente pode estourar o budget por até um ciclo antes do corte. Trade-off consciente: reduzir o intervalo dobraria as queries no Mimir por agente.
  • Não há briga de posse no spec.replicas: o patch é server-side apply com field manager próprio, dono de UM campo. E o ArgoCD nunca reverte porque os Deployments do planner/worker nem são gerenciados por ele — quem os cria é o operador kagent. Camadas que não se enxergam não brigam.
  • kubectl get xab é o runbook: as printer columns (BUDGET, SPENT, RATIO, PAUSED, CAUSE) fazem do próprio XR o resumo do incidente.

A wastegate em ação, em dois momentos:

# em cruzeiro — 29% do budget consumido, agente rodando:
kubectl get xab -n agent-housing-bot
NAME          BUDGET   SPENT    RATIO    PAUSED   CAUSE
housing-bot   5.00     1.4602   29.20%   false    running

# …depois de um dia de perguntas pesadas — passou de 100%, cortou ✂️
kubectl get xab -n agent-housing-bot
NAME          BUDGET   SPENT    RATIO     PAUSED   CAUSE
housing-bot   5.00     5.0213   100.43%   true     budget-exhausted

# e o corte é visível no motor — planner e worker zerados:
kubectl get deploy -n agent-housing-bot planner worker
NAME      READY   UP-TO-DATE   AVAILABLE   AGE
planner   0/0     0            0           3d2h
worker    0/0     0            0           3d2h

Nível 3 — XDatasetAgent: o kit Stage 3 completo 🏎️

Agora o chefe da fase. O XDatasetAgent (XDA) é o XR que representa um agente inteiro — e a Composition dele expande de UMA declaração para 22 recursos entre Kubernetes e GCP. É o kit Stage 3: motor forjado, turbo, intercooler, ECU, e até a wastegate eletrônica do Nível 2 instalada de fábrica.

Primeiro, o antes-e-depois que justifica tudo. Na versão anterior do whisperops, o skeleton do Backstage tinha ~20 templates Nunjucks — cada recurso do agente era um arquivo .njk renderizado pelo scaffolder e aplicado pelo ArgoCD. Funcionava, mas com dois custos: o template era um monstro de manter, e o ArgoCD gerenciava dezenas de recursos que sofriam mutação em runtime, exigindo 4 blocos de ignoreDifferences pra ele não brigar com o kill-switch do budget, com top-ups de orçamento e com defaults do Kyverno.

Depois da reescrita, o que o ArgoCD aplica é um arquivo (mais o catalog-info.yaml do Backstage, que fica na raiz do repo, fora do alcance dele):

# skeleton/manifests/xdatasetagent.yaml.njk — o pedido completo.
# A Composition renderiza os 22 recursos do agente a partir disto.
apiVersion: whisperops.io/v1alpha1
kind: XDatasetAgent
metadata:
  name: ${{ values.agent_name }}
spec:
  crossplane:
    compositionRef:
      name: xdatasetagent-default    # caminho v2 de escolher o manual
  agentName: ${{ values.agent_name }}
  datasetRef:
    name: ${{ values.dataset_id }}   # valida contra o catálogo (Nível 1)
  budgetUsd: ${{ values.budget_usd }}  # vira um XAgentBudget (Nível 2)
  description: "${{ values.description }}"
  baseDomain: ${{ values.base_domain }}
  projectId: ${{ values.project_id }}

E os 4 blocos de ignoreDifferences? Desapareceram. O ArgoCD agora gerencia um único recurso — o pedido — e todas as mutações de runtime acontecem nas peças compostas, abaixo do horizonte de visão dele. Essa é talvez a lição arquitetural mais importante do post: encolher a superfície gerenciada pelo GitOps dissolve os conflitos entre selfHeal e mutação de runtime, em vez de administrá-los exceção por exceção.

A Composition declara a linha com 6 estações + dyno:

# xdatasetagent-default.yaml — a linha de montagem completa
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
  name: xdatasetagent-default
spec:
  compositeTypeRef:
    apiVersion: whisperops.io/v1alpha1
    kind: XDatasetAgent
  mode: Pipeline
  pipeline:
    - step: validate-dataset      # 1. confere o combustível no catálogo
      functionRef: { name: function-xda-validate-dataset }
    - step: compute-tuning        # 2. dimensiona o motor pro dataset
      functionRef: { name: function-xda-compute-tuning }
    - step: render-iam            # 3. peças GCP: bucket, SA, IAM
      functionRef: { name: function-xda-render-iam }
    - step: render-workloads      # 4. peças K8s: 15 recursos
      functionRef: { name: function-xda-render-workloads }
    - step: render-dashboard      # 5. painel de instrumentos (Grafana)
      functionRef: { name: function-xda-render-dashboard }
    - step: emit-budget           # 6. instala a wastegate (XAgentBudget!)
      functionRef: { name: function-xda-emit-budget }
    - step: ready                 # dyno final
      functionRef: { name: function-auto-ready }

O fluxo de dados entre as estações usa os dois canais que você já conhece do Nível 2 — e aqui a distinção fica vital:

1. validate-dataset confere o dataset (Nível 1) 2. compute-tuning dimensiona o sandbox 3. render-iam 5 MRs GCP (bucket · SA · IAM) 4. render-workloads 15 MRs K8s (o grosso do motor) 5. render-dashboard 1 ConfigMap (dashboard Grafana) 6. emit-budget 1 XR aninhado — XAgentBudget! OS DOIS CANAIS DE DADOS CONTEXTO bilhete entre estações · morre no reconcile XR.STATUS prontuário durável · kubectl get xda

Estação por estação, com a nuance de cada uma:

1. validate-dataset — confere o combustível. Lê o XDataset do Nível 1 via extra resources. O padrão tem uma sutileza de bootstrap em duas fases que pega todo mundo de surpresa:

# A function DECLARA o que precisa; o Crossplane busca e RE-INVOCA.
selector = rsp.requirements.extra_resources["xdataset"]
selector.api_version = "whisperops.io/v1alpha1"
selector.kind = "XDataset"
selector.match_name = dataset_ref

# Na PRIMEIRA invocação, req.extra_resources vem VAZIO — o
# requirement ainda não foi atendido. A function precisa retornar
# cedo com um estado honesto (phase=Validating) em vez de falhar.
# Na invocação seguinte, o XDataset vem populado.
if not xds_items:
    resource.update(rsp.desired.composite, {"status": {
        "phase": "Validating",
        "conditions": [{"type": "Ready", "status": "Unknown",
                        "reason": "BootstrappingExtraResources", ...}],
    }})
    return rsp

# Dataset não existe ou not-ready? Falha CEDO, com causa legível.
# Melhor um XR Failed com "DatasetNotFound" do que um sandbox
# crashando 10 minutos depois com um erro de GCS.

Validado, ele coloca {name, gcsPath, sizeBytes, displayName} no contexto.

2. compute-tuning — dimensiona o motor. Um carburador maior pede mais combustível; um dataset maior pede mais memória no sandbox. A heurística: pandas ocupa ~3,5× o tamanho do CSV em RAM; arredonda pro próximo GiB, com piso de 1 GiB e teto de 8 GiB:

PANDAS_BLOAT_FACTOR = 3.5     # validado contra os datasets reais

def compute_sandbox_mem_mi(size_bytes: int) -> int:
    raw_mib = math.ceil((size_bytes * PANDAS_BLOAT_FACTOR) / (1024 * 1024))
    gib_rounded = math.ceil(raw_mib / GIB_IN_MIB) * GIB_IN_MIB
    return max(MIN_SANDBOX_MIB, min(gib_rounded, MAX_SANDBOX_MIB))

Essa estação também carrega a pegadinha mais traiçoeira do SDK Python: o resource.update(rsp.desired.composite, {"status": {...}}) faz update RASO — o bloco status inteiro é SUBSTITUÍDO, apagando o que a estação anterior escreveu. A solução é ler-mesclar-escrever:

# Lê o status desejado ACUMULADO (que veio das estações anteriores),
# mescla o campo novo, e escreve o bloco completo de volta.
# Sem isso, sandboxMemMi apagaria phase/datasetFmt/conditions
# escritos pelo validate-dataset.
desired_status = (
    resource.struct_to_dict(rsp.desired.composite.resource).get("status") or {}
)
desired_status["sandboxMemMi"] = sandbox_mem_mi
resource.update(rsp.desired.composite, {"status": desired_status})

3. render-iam — as peças GCP. Emite 5 Managed Resources dos providers GCP do Crossplane: o bucket do agente, um ServiceAccount, uma ServiceAccountKey e duas ProjectIAMMember — com condições CEL do IAM restringindo cada grant a exatamente o bucket certo (viewer no bucket compartilhado de datasets, admin só no bucket do próprio agente). Least-privilege por agente, gerado por código.

4. render-workloads — o grosso do motor. 15 manifests Kubernetes embrulhados em Object MRs: o Namespace do agente, ConfigMap de prompts, NetworkPolicy, ModelConfig e os dois Agent CRs do kagent (planner + worker), sandbox (Deployment + Service + RemoteMCPServer, o tool-server MCP do kagent), chat-frontend (SA + RoleBinding + Deployment + Service + Ingress) e uma policy Kyverno. Duas decisões de design fazem essa estação funcionar:

# DECISÃO 1 — o ovo e a galinha do namespace:
# O XDA é cluster-scoped porque ele CRIA o namespace em que as peças
# vivem (não dá pra morar dentro do que você ainda vai construir).
# Mas um Object MR namespaced precisa existir num namespace que JÁ
# exista na hora do create. Solução: o MR mora em crossplane-system
# (sempre existe), enquanto o manifesto embrulhado aponta pro
# namespace do agente — o provider-kubernetes tenta aplicar, falha
# enquanto o namespace não existe, e converge sozinho quando a peça
# Namespace assenta. Consistência eventual fazendo o trabalho.
return {
    "apiVersion": "kubernetes.m.crossplane.io/v1alpha1",
    "kind": "Object",
    "metadata": {"name": mr_name, "namespace": "crossplane-system"},
    "spec": {"forProvider": {"manifest": manifest},   # ← alvo: agent-{name}
             "providerConfigRef": {"kind": "ClusterProviderConfig",
                                    "name": "in-cluster"}},
}

# DECISÃO 2 — dois sabores de Object coexistindo:
# O Namespace (cluster-scoped) usa o Object LEGADO
# (kubernetes.crossplane.io/v1alpha2), porque o flavor .m. só tem
# Object namespaced. Já as peças namespaced usam o flavor .m. por
# CONVENÇÃO do Crossplane v2 — na Parte 1 embrulhamos um ConfigMap
# no Object legado e funcionou: um XR cluster-scoped PODE compor
# Objects legados envolvendo manifests namespaced. A regra DURA de
# scope ("XR namespaced só compõe MR namespaced") obriga apenas no
# XAgentBudget do Nível 2. Dois provider configs coexistem, ambos
# "in-cluster": um ProviderConfig (grupo legado) e um
# ClusterProviderConfig (grupo .m.).

5. render-dashboard — o painel de instrumentos. Renderiza um dashboard Grafana por agente (ConfigMap a partir de um template JSON). Detalhe de veterano: a substituição usa str.replace com sentinelas (__AGENT__), nunca str.format() — as chaves do JSON do Grafana explodiriam o .format(). E o resultado passa por json.loads antes de virar ConfigMap, pra falhar no reconcile e não silenciosamente no Grafana.

6. emit-budget — a wastegate de fábrica. A estação final emite… outro XR. O XAgentBudget do Nível 2 nasce aqui, como peça composta do XDA:

# Composition-of-compositions: o XDA emite um XAgentBudget, que tem
# a PRÓPRIA pipeline (fetch-spend → decide → render) rodando no
# próprio ciclo de reconcile. Emitir um XR é idêntico a emitir um
# MR — só muda o kind embrulhado.
xab = {
    "apiVersion": "whisperops.io/v1alpha1",
    "kind": "XAgentBudget",
    "metadata": {
        # CONTRATO: o nome do XAB == nome do agente. O chat-frontend
        # busca getXAgentBudget("agent-{name}", "{name}") — um
        # mismatch quebra o chat com um 503 enganoso.
        "name": agent,
        "namespace": f"agent-{agent}",
    },
    "spec": {
        "crossplane": {"compositionRef": {"name": "xagentbudget-default"}},
        "agentName": agent,
        "budgetUsd": budget_usd,
        "pricingRef": {"name": "whisperops-pricing",
                       "namespace": "crossplane-system"},
        "enforcement": "enabled",
    },
}
resource.update(rsp.desired.resources["xagentbudget"], xab)

Esse aninhamento é o que permite ao Nível 2 existir como produto independente: o budget tem ciclo de vida, reconcile e API próprios — o XDA só o instancia. Como a wastegate eletrônica que você compra à parte, mas que o kit Stage 3 já traz instalada.

O inventário completo, pra fechar a conta das 22 peças:

EstaçãoPeçasO quê
render-iam5Bucket, ServiceAccount, ServiceAccountKey, 2× ProjectIAMMember (GCP)
render-workloads15Namespace, prompts CM, NetworkPolicy, ModelConfig, 2× Agent (kagent), sandbox (Deploy+Svc+RemoteMCPServer), chat-frontend (SA+RB+Deploy+Svc+Ingress), Kyverno policy
render-dashboard1ConfigMap com o dashboard Grafana do agente
emit-budget1XAgentBudget (XR aninhado)

E o resultado no terminal — o XR com as printer columns derivadas do status que as estações gravaram:

kubectl get xda
NAME          DATASET              BUDGET   READY   URL                                                   AGE
housing-bot   California Housing   5.00     True    https://agent-housing-bot.34.61.7.12.sslip.io:8443/   12m

E abrindo o capô com o crossplane beta trace — a árvore do XR com as 22 peças penduradas (saída resumida):

crossplane beta trace xdatasetagent housing-bot
NAME                                                SYNCED   READY
XDatasetAgent/housing-bot                           True     True
├─ Object/agent-housing-bot-namespace               True     True
├─ Object/agent-housing-bot-agent-planner           True     True
├─ Object/agent-housing-bot-agent-worker            True     True
├─ Object/agent-housing-bot-sandbox-deployment      True     True
├─ Object/agent-housing-bot-chat-frontend-deploy…   True     True
├─ Bucket/agent-housing-bot-bucket                  True     True
├─ ServiceAccount/agent-housing-bot-sa              True     True
├─ XAgentBudget/housing-bot                         True     True
└─ (mais 14 peças)

O fluxo completo: do formulário ao chat 💬

Juntando os três níveis, o caminho de um clique no Backstage até um agente conversando:

1 DEV preenche 4 campos: nome · descrição · dataset · budget 2 fetch:template renderiza o skeleton (1 XR + catalog-info) 3 publish:gitea cria o repo agent-<nome> e dá push 4 cnoe:create-argocd-app cria a Application (path: manifests/) 5 ArgoCD sync → kubectl apply do XDatasetAgent 6 Crossplane roda a linha: 6 estações → 22 peças 7 kagent sobe planner + worker (Vertex AI) 8 chat-frontend no ar → o dev conversa com o dataset

Dois truques do template que valem nota:

  • Parâmetros ocultos com sed-bake: base_domain e project_id são campos ui:widget: hidden com defaults-sentinela (__BASE_DOMAIN__). No deploy, o bootstrap consulta o metadata server da VM e “assa” os valores reais no template via sed. O dev nunca digita IP nem project ID — e como os campos ocultos têm regex de validação, um sed-bake que falhe quebra o formulário ruidosamente, em vez de scaffoldar com placeholder.
  • path: manifests na Application: o catalog-info.yaml fica na raiz do repo (pro Backstage descobrir) e fora do alcance do ArgoCD (que só olha manifests/). Sem isso, o ArgoCD tentaria aplicar uma entity do Backstage como recurso K8s e ficaria eternamente OutOfSync.

E o Day-2? Mudar o budget de um agente vivo, trocar o dataset, destruir o agente — tudo são templates do Backstage também, e todos respeitam o GitOps: a mudança vai pro Git primeiro, e o ArgoCD a aplica. Um Job in-cluster faz GET no arquivo via API do Gitea (capturando o SHA), edita a linha exata com sed/awk, faz PUT com o SHA anterior (concorrência otimista) e anota a Application com refresh=hard pra não esperar o poll. Nada de kubectl patch no recurso vivo — o chefe de oficina reverteria em segundos, e com razão.

O destroy merece o diagrama, porque a ordem é a lição:

destroy-agent — a ordem importa 0 desliga o selfHeal da Application senão o ArgoCD RECRIA o XR! 1 kubectl delete xdatasetagent cascata desmonta as 22 peças 2 remove finalizers + deleta a Application 3 deleta o namespace (defensivo) 4 deleta o repo no Gitea a fonte de verdade, por último! 5 expurga as entities do Backstage 6 evento de auditoria no Loki quem · o quê · quando

Deletar o repo antes do XR inverteria a corrida: o selfHeal perderia a fonte, mas o XR órfão ficaria pra trás. Em GitOps, teardown é coreografia: você silencia o reconciler, desmonta o estado, e só então apaga o projeto do caderno.

Lições da oficina (o que eu quebrei pra você não quebrar) ⚠️

Consolidando as nuances que apareceram pelo caminho, mais algumas que só aparecem em produção:

  1. A corrida do CRD-establish, em três escalas. Um Provider do Crossplane é um pacote: o CR sincroniza em segundos, mas os CRDs que ele instala demoram minutos. Se o ProviderConfig estiver na mesma Application, o dry-run do ArgoCD falha (“kind não existe”). O whisperops defende em três camadas: Applications separadas com sync waves (providers na wave 3, config na wave 5), retry com backoff exponencial, e SkipDryRunOnMissingResource=true anotado no recurso. A mesma corrida reaparece dentro do app de providers (o DeploymentRuntimeConfig precisa anteceder o Provider) e no conteúdo (XRs de exemplo são excluídos do sync com exclude: "examples/*", porque aplicar um XR antes da XRD estabelecer derruba o sync inteiro).
  2. ignoreDifferences sozinho não basta — ele só muda o diff. Pra o selfHeal não sobrescrever o campo, precisa de RespectIgnoreDifferences=true nos syncOptions. E a versão madura dessa lição: se você precisa de muitos ignoreDifferences, talvez o problema seja a superfície que o ArgoCD gerencia — encolha-a (foi exatamente a reescrita XDA).
  3. Sync waves ordenam o apply, não a prontidão — e anotações de sync-wave em recursos compostos pelo Crossplane são decorativas (quem cria é o Crossplane, que as ignora). Para ordenação real entre camadas assíncronas, o whisperops usa um PreSync hook que faz poll do pré-requisito — convertendo estado assíncrono em precondição dura.
  4. spec.names de XRD é imutável no Crossplane v2 (CEL self == oldSelf). Adicionar um shortName num cluster vivo é rejeitado na admissão; só entra num recreate. Planeje os nomes na primeira versão.
  5. Os footguns do function-sdk-python: proto Struct não tem .get() (converta com resource.struct_to_dict), atribuição aninhada em Struct explode (use resource.update), o update de status é raso (leia-mescle-escreva), e req.extra_resources é um MessageMap que o conversor padrão não entende (itere as chaves). Nenhum deles aparece em tutorial; todos aparecem no primeiro pipeline real.
  6. Contexto propaga — desde que cada estação coopere. No SDK Python, response.to(req) copia o contexto recebido pra resposta; uma function que monte a resposta na mão, sem esse helper, descarta o bilhete silenciosamente e as estações seguintes enxergam nada. O whisperops re-emite as chaves críticas explicitamente, como cinto-e-suspensório. E a distinção continua valendo: contexto é bilhete de estação (morre no fim do reconcile); status é o prontuário durável.
  7. packagePullPolicy: Always em functions com tag :latest — senão o digest fica cacheado e os pods de function continuam rodando código velho depois de um rebuild, silenciosamente.
  8. Falhe aberto onde a falha do sistema de medição não pode punir o medido (fetch-spend com Mimir fora), e falhe cedo e legível onde o pedido é inválido (DatasetNotFound na estação 1, regex no formulário, limites na XRD). A validação em camadas — browser, admissão, pipeline — dá feedback no ponto mais barato possível.

Monte a sua 🛠️

O checklist pra adaptar isso ao seu contexto, em ordem de implementação:

  1. Comece pelo triângulo vazio: idpbuilder create, Crossplane via Helm, provider-kubernetes. Reproduza o laboratório da Parte 1 até o passo do “edite no Git e veja propagar”. Sem isso sólido, o resto desaba.
  2. Modele UMA abstração magra. Escolha o recurso que seu time mais pede (um serviço web? um banco? um bucket?) e desenhe a XRD com o mínimo de campos — se o formulário precisa de mais de 5, a abstração está vazando.
  3. Composition em modo Pipeline desde o dia 1, mesmo que com uma função genérica (function-go-templating) + function-auto-ready. Migrar de patch-and-transform depois dói mais.
  4. Quando a lógica crescer, escreva functions próprias (Python ou Go). Validação com falha legível primeiro, render depois, auto-ready sempre por último. Status durável no XR; contexto só entre estações.
  5. Backstage entra por último — quando o fluxo manual (push + Application) estiver redondo. O template do scaffolder é só o formulário na frente do que você já provou funcionar.
  6. Day-2 nasce GitOps: toda mutação vai pro Git primeiro. Se você se pegar escrevendo kubectl patch num runbook, volte duas casas.
  7. Registre as corridas que você perder. CRD-establish, namespace chicken-and-egg, selfHeal vs runtime — todas têm solução declarativa (waves, retry, hooks, scoping). A gambiarra de estacionamento aguenta até o chefe de oficina passar.

A régua de sucesso é a mesma da oficina: o cliente escolhe o kit no balcão, assina uma ficha de uma página, e dias de trabalho artesanal viram minutos de linha de montagem — com o chefe garantindo que cada carro na rua é idêntico ao projeto no caderno. Quando o seu kubectl get mostrar um XR Ready: True com 22 peças penduradas nele, você vai entender por que eu chamo isso de motor que se remonta sozinho.


Referências 📚

Documentação oficial:

Ferramentas e código:

Pesquisa e fundamentos:

Leituras complementares:

Leia também