← Voltar ao blog

Crossplane Compositions + ArgoCD: do pedido ao motor montado

Crossplane Compositions + ArgoCD: do pedido ao motor montado Crossplane Compositions + ArgoCD: do pedido ao motor montado

⚠️ Aviso de fábrica: eu gosto de carros. Muito. Então já peço desculpas adiantadas pois este post vai lotado de referências a oficina e preparação de motores. 🏎️ 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, umas ferramentas 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 — e o foco desta aula são os dois que fazem o trabalho pesado:

  • Crossplane é a linha de montagem — o coração do post.
  • ArgoCD é o chefe de oficina — quem mantém o carro na rua idêntico ao projeto na bancada.
  • Backstage é o balcão — o front self-service, que entra como um plus.

Este post é uma aula em duas partes sobre Crossplane Compositions + ArgoCD. Na Parte 1, monto a linha de montagem e o chefe de oficina do zero, com um laboratório local que você pode copiar e colar — baby steps mesmo; no fim, mostro onde o balcão (CNOE/Backstage) se encaixa por cima. Na Parte 2, abro o capô do whisperops, um projeto real que usa essa combinação pra entregar agentes de IA self-service, e disseco dois recursos customizados em ordem crescente de complexidade: 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 papéis, 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 sempre leva o $ na frente. Os passos do template leem os campos do formulário com ${{ parameters.x }} (acima); o skeleton/ que eles renderizam lê os mesmos valores como ${{ values.x }} (você verá no Passo 7). 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
      # printer columns: TEAM e STAGE aparecem direto no `kubectl get xgarage`.
      # O Crossplane mescla com as default dele (SYNCED/READY/COMPOSITION/AGE),
      # pondo as nossas logo após o NAME.
      additionalPrinterColumns:
        - { name: TEAM,  type: string, jsonPath: .spec.teamName }
        - { name: STAGE, type: string, jsonPath: .spec.stage }
      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]

v1 vs v2 do Crossplane — sobre o apiVersion da XRD. Nesse cenário eu uso apiextensions.crossplane.io/v1 com scope explícito: é o que roda no Crossplane 2.x (e o que o whisperops usa). A forma canônica do v2 é apiextensions.crossplane.io/v2 — mesmos campos, só que com scope: Namespaced como default e sem as Claims legadas. O que o v2 mudou (e este post usa): XRs podem ser namespaced (Parte 2), as Claims sumiram, a Composition é só pipeline/functions (o patch-and-transform nativo saiu) e os Managed Resources podem ser namespaced (os grupos .m.). Tudo gira no campo scope: Namespaced (default no v2), Cluster ou LegacyCluster (modo v1).

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:
              # NÃO setamos metadata.name: o Crossplane nomeia o MR como
              # <nome-do-XR>-<sufixo> de qualquer jeito (o name aqui é
              # ignorado). O handle lógico é a annotation abaixo; pra achar
              # o MR depois, mire o label crossplane.io/composite (ver Passo 5).
              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:
              # idem: sem metadata.name; o nome do MR é gerado pelo Crossplane
              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…) — mantenha a flag: o Backstage opcional do Passo 7
# (se você instalá-lo) entra sob o mesmo hostname, sem trocar URLs.
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 — TEAM e STAGE vêm das printer columns da XRD (sem -o nenhum)
kubectl get xgarage
# NAME           TEAM   STAGE    SYNCED   READY   COMPOSITION       AGE
# projeto-ae86   ae86   stage2   True     True    xgarage-default   30s

# as peças que ele expandiu
kubectl get objects.kubernetes.crossplane.io
# o nome do MR é <nome-do-XR>-<sufixo gerado pelo Crossplane>; o SEU
# sufixo vai ser outro, e a coluna KIND é que diz qual peça é qual.
# NAME                        KIND        PROVIDERCONFIG   SYNCED   READY   AGE
# projeto-ae86-7f891ae5e578   Namespace   in-cluster       True     True    40s
# projeto-ae86-263eaaca27af   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. Como o nome do MR tem o sufixo gerado, mire pelo label crossplane.io/composite em vez de adivinhar o nome (de quebra, pega as duas peças do XR de uma vez):

kubectl annotate objects.kubernetes.crossplane.io \
  -l crossplane.io/composite=projeto-ae86 \
  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.

# do dir garage-ae86 (onde você deu push no Passo 6): troca o stage,
# commita e dá push — o ArgoCD faz o resto.
# (macOS/BSD precisa do '' depois do -i; no Linux/GNU é só: sed -i 's/.../.../')
sed -i '' 's/stage: stage2/stage: stage3/' manifests/xgarage.yaml
git commit -am "stage3 no ae86"
git -c http.sslVerify=false push

# acompanhe a propagação
kubectl get application -n argocd garage-ae86 -w
kubectl get xgarage   # a coluna STAGE vira stage3 quando o ArgoCD sincronizar

Passo 7 (opcional) — onde o Backstage entra. Repare no que você fez nos Passos 5–6: escreveu um pedido (o XR), deu git push e registrou uma Application. Esse é o front manual da plataforma. O Backstage não faz mágica — ele só põe um formulário na frente desse fluxo. O scaffolder dele roda três ações que são, uma a uma, o que você já fez na mão:

Ação do scaffolderO equivalente manual (Passo 6)
fetch:templaterenderizar o xr.yaml com os valores do pedido
publish:giteacriar o repo + git push
cnoe:create-argocd-appo kubectl apply da Application

O template (a ficha de pedido que mostrei lá no início) tem um skeleton/ que é literalmente o seu xr.yaml com placeholders — o formulário só preenche os ${{ values.x }}:

# skeleton/manifests/xgarage.yaml — o que o fetch:template renderiza
apiVersion: blog.opsbogus.dev/v1alpha1
kind: XGarage
metadata:
  name: garage-${{ values.team_name }}
spec:
  teamName: ${{ values.team_name }}
  stage: ${{ values.stage }}

Como o CNOE resolve o scaffolding pra você. Montar um Backstage do zero (imagem custom com as actions de gitea/argocd, app-config, auth, catálogo) é um projeto à parte. A comunidade CNOE empacota tudo isso pronto: o idpbuilder sobe, com um comando, um Backstage pré-buildado (já com as actions publish:gitea e cnoe:create-argocd-app), Keycloak pra auth e Argo Workflows — o portal self-service inteiro, sem você escrever uma linha de Backstage.

Se quiser ver ao vivo (opcional), instale o pacote de referência do CNOE por cima do mesmo cluster da Parte 1:

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

Em ~6 min o portal sobe sob o mesmo hostname; aí é Create… → Register Existing Component apontando pro template.yaml, preencher o form, e ver o scaffolder rodar as três ações da tabela — criando o mesmo XGarage que você criou na mão.

⚠️ Aviso honesto: o ref-implementation é uma stack upstream da comunidade (Backstage + Keycloak + Argo Workflows + external-secrets), separada do que você montou aqui, e pode tropeçar no primeiro rollout. Se o portal der 503 no login, comece o diagnóstico por kubectl get applications -n argocd — um app backstage em Degraded é o sintoma. O miolo da plataforma são os Passos 1–6; o portal é só uma cara mais bonita por cima.

O Backstage merece um post só dele — em breve vou mergulhar fundo em montar portais self-service em cenários como este (actions custom, auth, app-config, catálogo). Aqui ele entra como o que é: a cereja do bolo sobre Crossplane + ArgoCD.

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 a linha de montagem e o chefe de oficina rodando localmente, e sabe exatamente onde o balcão se encaixa. 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: dois recursos customizados em ordem crescente de complexidade. Vou apresentá-los como dois níveis de preparação:

  1. XAgentBudget — a wastegate eletrônica com corte (pipeline de 3 funções)
  2. XDatasetAgent — o kit Stage 3 completo (pipeline de 6 funções, 22 peças)

Nível 1 — 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 princípio que guia a arquitetura: o status do XR é a única fonte de verdade sobre “esse agente está pausado?” — escrito por um pipeline e lido por todo mundo (frontend, probes, classificador de erro). Um único lugar decide; ninguém infere por conta própria.

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. É essa separação que mantém o sinal honesto: pausa por orçamento (402) nunca se disfarça de erro de infra (503).

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 2 — 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 1 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 que o dataset existe
  budgetUsd: ${{ values.budget_usd }}  # vira um XAgentBudget (Nível 1)
  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: o dataset existe?
      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 1 — e aqui a distinção fica vital:

1. validate-dataset confere o dataset 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. O XDataset aqui é só um registro simples — um CRD que aponta pra um CSV no bucket GCS — que a estação lê via extra resources do Crossplane. 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 1. 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 1 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 1 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 dois 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