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:
- 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).
- 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 deinvalid 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:
| Conceito | Na oficina | O que é |
|---|---|---|
| XRD (CompositeResourceDefinition) | A homologação da ficha de pedido | Define a API do seu recurso composto: quais campos o pedido aceita, quais são obrigatórios, qual o regex de cada um |
| Composition | O manual de montagem do kit | Diz 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 umProviderConfigantes 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.
A divisão de responsabilidades:
- 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”.
- 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.
- 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-ae86mostra 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 combrew install crossplaneou 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.
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:
XDataset— o catálogo de combustíveis homologados (nem é Crossplane!)XAgentBudget— a wastegate eletrônica com corte (pipeline de 3 funções)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:
- Lista os
*.csvdo bucketgs://whisperops-datasets/. - Para cada blob, faz upsert de um
XDataset(normalizando o nome:Athlete_Recovery.csvviraathlete-recovery, mas ospec.gcsPathpreserva o nome original do blob). - Publica em paralelo uma entity
Resourcedo Backstage num repositório Git de catálogo — é daqui que o dropdown do formulário se alimenta. - 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 lê 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:
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):
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:
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ção | Peças | O quê |
|---|---|---|
| render-iam | 5 | Bucket, ServiceAccount, ServiceAccountKey, 2× ProjectIAMMember (GCP) |
| render-workloads | 15 | Namespace, prompts CM, NetworkPolicy, ModelConfig, 2× Agent (kagent), sandbox (Deploy+Svc+RemoteMCPServer), chat-frontend (SA+RB+Deploy+Svc+Ingress), Kyverno policy |
| render-dashboard | 1 | ConfigMap com o dashboard Grafana do agente |
| emit-budget | 1 | XAgentBudget (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:
Dois truques do template que valem nota:
- Parâmetros ocultos com sed-bake:
base_domaineproject_idsão camposui:widget: hiddencom defaults-sentinela (__BASE_DOMAIN__). No deploy, o bootstrap consulta o metadata server da VM e “assa” os valores reais no template viased. 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: manifestsna Application: ocatalog-info.yamlfica na raiz do repo (pro Backstage descobrir) e fora do alcance do ArgoCD (que só olhamanifests/). 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:
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:
- A corrida do CRD-establish, em três escalas. Um
Providerdo Crossplane é um pacote: o CR sincroniza em segundos, mas os CRDs que ele instala demoram minutos. Se oProviderConfigestiver 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),retrycom backoff exponencial, eSkipDryRunOnMissingResource=trueanotado no recurso. A mesma corrida reaparece dentro do app de providers (oDeploymentRuntimeConfigprecisa anteceder o Provider) e no conteúdo (XRs de exemplo são excluídos do sync comexclude: "examples/*", porque aplicar um XR antes da XRD estabelecer derruba o sync inteiro). ignoreDifferencessozinho não basta — ele só muda o diff. Pra o selfHeal não sobrescrever o campo, precisa deRespectIgnoreDifferences=truenos syncOptions. E a versão madura dessa lição: se você precisa de muitosignoreDifferences, talvez o problema seja a superfície que o ArgoCD gerencia — encolha-a (foi exatamente a reescrita XDA).- 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.
spec.namesde XRD é imutável no Crossplane v2 (CELself == oldSelf). Adicionar um shortName num cluster vivo é rejeitado na admissão; só entra num recreate. Planeje os nomes na primeira versão.- Os footguns do function-sdk-python: proto
Structnão tem.get()(converta comresource.struct_to_dict), atribuição aninhada em Struct explode (useresource.update), o update de status é raso (leia-mescle-escreva), ereq.extra_resourcesé umMessageMapque o conversor padrão não entende (itere as chaves). Nenhum deles aparece em tutorial; todos aparecem no primeiro pipeline real. - 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. packagePullPolicy: Alwaysem 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.- 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:
- 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. - 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.
- 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. - 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.
- 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.
- Day-2 nasce GitOps: toda mutação vai pro Git primeiro. Se você se pegar
escrevendo
kubectl patchnum runbook, volte duas casas. - 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:
- Backstage — Software Templates e Writing Templates — o scaffolder e a sintaxe
${{ }} - Backstage — Software Catalog — o catálogo que alimenta o EntityPicker
- Crossplane — Compositions — Compositions e o modo Pipeline
- Crossplane — Composite Resource Definitions — XRDs, scopes e versionamento
- Crossplane — Write a Composition Function in Python — o guia oficial do SDK Python
- Argo CD — Cluster Bootstrapping (app-of-apps)
- Argo CD — Sync Phases and Waves
- Argo CD — Automated Sync Policy — prune, selfHeal e amigos
- CNOE e idpbuilder — o IDP local de um comando
- kagent — o framework de agentes de IA cloud-native
- Kyverno — Generate Rules — o quarto pilar do BACK stack
Ferramentas e código:
- crossplane/function-sdk-python — o SDK das functions Python
- crossplane-contrib/provider-kubernetes — o provider dos Object MRs
- cnoe-io/idpbuilder — código e pacotes do idpbuilder
- gitops-bridge-dev/gitops-bridge — padrões pra ponte Terraform → GitOps
- whisperops — o projeto dissecado na Parte 2 (página no LAB)
Pesquisa e fundamentos:
- CNCF Platforms White Paper — a definição canônica de plataforma interna
- DORA Research — a pesquisa que liga self-service e automação a performance de entrega
- Team Topologies — Key Concepts — platform teams e o Thinnest Viable Platform
- Accelerating Control Systems with GitOps (arXiv) — pesquisa recente sobre GitOps como fonte única de verdade
Leituras complementares:
- The BACK Stack e o post de introdução — a stack de referência Backstage + ArgoCD + Crossplane + Kyverno
- Crossplane Blog — Introducing function-python
- Crossplane Blog — Composition Functions in Production — case real da VSHN
- CNCF TAG App Delivery — Platform Engineering trends
- Argo CD Application Dependencies — app-of-apps + sync waves
- Building an IDP with Backstage, AKS, Crossplane and Argo CD — o mesmo triângulo em outra nuvem, pra contraste