Skip to main content

Composition Concepts

Esta es la parte más importante de Crossplane. La Composition es el corazón de Crossplane y lo que lo hace tan poderoso para abstraer cualquier cosa.

Vamos a entender a partir de ahora que los managed resources son apenas piezas que utilizamos para montar una solución. Para utilizar esas piezas usamos las APIs que son creadas por cada provider. Lo que hicimos antes fue crear pieza por pieza, sin un controller para la solución como un todo. Eliminar la solución envolvería eliminar cada una de las partes en orden inverso.

Cuando estudiamos Kubernetes aprendemos qué es un pod, después un replicaset y después un deployment. Entendemos que un deployment crea replicasets y estos crean los pods. Si fuéramos crear un pod el nombre del pod es lo que definimos. Pero si creamos un deployment veremos que el pod recibe algunos sufijos al nombre.

# Creando un pod sin controlador
❯ kubectl run myapp --image nginx
pod/myapp created

# Creando un deployment que crea un replicaset, que crea un pod
❯ kubectl create deployment myapp --image nginx
deployment.apps/myapp created

❯ kubectl get pods
NAME READY STATUS RESTARTS AGE
myapp 1/1 Running 0 25s
myapp-5b5df85c44-z2795 1/1 Running 0 12s

❯ kubectl get rs
NAME DESIRED CURRENT READY AGE
myapp-5b5df85c44 1 1 1 12s

Podemos observar que el número 5b5df85c44 fue un número aleatorio creado por el deployment para el replicaset y el nombre del replicaset inicia con el nombre del pod seguido también de un número aleatorio z2795.

Eliminar el deployment involucra eliminar el replicaset y por su vez el pod. Lo que queremos mostrar es que existe un controller para eso y al final de cuentas composition es el controller para todos los recursos que creamos en su definición.

  • Composition funciona como un controller de alto nivel, similar al Deployment en Kubernetes.
  • Jerarquía y nombres generados automáticamente que los resources manages recibirán
  • Un único recurso (Composition) puede gestionar varios recursos subyacentes, facilitando la creación y eliminación de toda la solución de una vez.

Así como el Deployment abstrae detalles de implementación (como la generación de ReplicaSets), la Composition en Crossplane abstrae la complejidad de los recursos de infraestructura. Esto permite que desarrolladores consuman recursos complejos sin necesitar entender todos los detalles subyacentes.

Compositions nos permite definir lo que algo es y codificar nuestra expertise y exponer servicios como abstracciones.

Conceptos Inicial de la API de Kubernetes

No vamos a entrar a fondo en esto es solo un overview rápido.

Al utilizar un Custom Resource Definition (CRD) en Kubernetes extendiendo la API que el kube-api-server consigue interpretar, y esa extensión es definida usando un esquema basado en OpenAPI (anteriormente conocido como Swagger).

Cuando creas un CRD, defines la estructura de tu nuevo resource (recurso) personalizado usando un esquema que sigue las especificaciones de OpenAPI v3 para describir:

  • Los campos que tu recurso personalizado tendrá
  • Los tipos de datos de esos campos
  • Validaciones que deben ser aplicadas
  • Descripciones para documentación

Kubernetes utiliza esas definiciones de esquema OpenAPI para:

  • Validar objetos cuando son creados o actualizados
  • Generar documentación para tu API extendida
  • Proporcionar informaciones de tipo para clientes como kubectl

Este enfoque permite que extiendas Kubernetes de forma estandarizada, aprovechando los mismos mecanismos que el propio Kubernetes usa para sus APIs nativas.

Aquí un ejemplo de un CRD de kubernetes. Estamos creando una API y sus parámetros y creando algo nuevo.

apiVersion: apiextensions.k8s.io/v1 # Mira que estamos extendiendo directamente del k8s.
kind: CustomResourceDefinition
metadata:
name: webapps.example.com
spec:
group: example.com
names:
kind: WebApp
plural: webapps
singular: webapp
shortNames:
- wa
scope: Namespaced
versions:
- name: v1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
required:
- image
- replicas
properties:
image:
type: string
description: "La imagen del contenedor a ser usada para la aplicación web"
replicas:
type: integer
minimum: 1
maximum: 10
description: "Número de réplicas de la aplicación web"
port:
type: integer
default: 80
description: "Puerto que la aplicación expone"
env:
type: array
description: "Variables de entorno para el contenedor"
items:
type: object
required:
- name
- value
properties:
name:
type: string
value:
type: string
additionalPrinterColumns:
- name: Replicas
type: integer
description: Número de réplicas
jsonPath: .spec.replicas
- name: Image
type: string
description: Imagen del contenedor
jsonPath: .spec.image
- name: Age
type: date
jsonPath: .metadata.creationTimestamp

Con esa definición arriba, podríamos aplicar el siguiente manifiesto.

apiVersion: apps.example.org/v1alpha1
kind: WebApp
metadata:
name: mi-aplicacion
namespace: default
spec:
appName: mi-aplicacion
tier: medium
region: us-east-1
public: true
environment:
DATABASE_URL: ${.db.connection}
LOG_LEVEL: info
DEBUG: "true"

Pero si aplicamos el manifiesto arriba ¿qué va a suceder?

No sucede automáticamente nada más allá del almacenamiento de ese objeto en la API de Kubernetes. El simple hecho de definir un CRD y crear una instancia de él no resulta automáticamente en la creación de pods, deployments o cualquier otro recurso. Para que algo suceda cuando creas ese objeto WebApp, necesitas de un controlador personalizado que:

  • Observe los recursos del tipo WebApp
  • Reaccione a los cambios de esos recursos
  • Cree/actualice/elimine los recursos subyacentes (como Deployments, Services, etc.)

Sin un controlador, tu recurso WebApp quedará apenas almacenado en el etcd de Kubernetes, pero no tendrá ningún efecto práctico en el clúster. Es esencialmente un dato estructurado que no hace nada por sí solo. El flujo completo sería:

  1. Defines el CRD WebApp (estructura de datos)
  2. Implementas e implementas un controlador personalizado para procesar objetos WebApp
  3. Creas una instancia WebApp llamada "mi-webapp"
  4. El controlador detecta el nuevo objeto y crea los recursos subyacentes (Deployment, Service, etc.)

Es exactamente así como operators trabajan en Kubernetes si estás más familiarizado con ese término.

Crossplane posee un CRD llamado CompositeResourceDefinition que actúa como un "Custom Resource definition" dentro del ecosistema de Crossplane. Quien reaccionará a este recurso será Crossplane, entonces estamos saltando la etapa 2 de implementar un controlador.

CompositeResourceDefinition (XRD)

CompositeResourceDefinition (XRD) de Crossplane es efectivamente una especie de Custom Resource Definition (CRD), pero con funcionalidades extendidas y un propósito específico dentro del ecosistema Crossplane.

Crossplane Core que corre en el pod de crossplane en el namespace crossplane-system es el responsable de gestionar los CompositeResourceDefinitions (XRDs). Específicamente, el controlador de compositions (Composition Controller) que forma parte de Crossplane Core es el componente que observa, procesa y gestiona el ciclo de vida de los recursos compuestos definidos a través de XRDs. Este controlador es implementado como parte de la instalación principal de Crossplane y funciona independientemente de los providers específicos. Monitorea la API de Kubernetes para eventos relacionados a XRDs, recursos compuestos (XRs) y claims (XRCs), e implementa toda la lógica necesaria para traducir esos recursos de alto nivel en recursos gestionados de acuerdo con las definiciones de Composition.

Diferente de los recursos específicos de providers (como recursos AWS, GCP, etc.), que son gestionados por los controladores de cada provider, los recursos de compositions son siempre gestionados por este controlador central de Crossplane.

Podríamos hacer la siguiente analogía:

  • Controlador de composition ≈ Controlador de Deployment
  • Providers ≈ Controladores de ReplicaSet
  • Recursos gestionados por los providers ≈ Pods

Vamos entonces a utilizar el controlador de composition para crear todo lo que necesitamos y extender la API a nuestro favor!

Después de que entendemos Kubernetes, raramente creamos pods sin ser a través de deployments. Cuando entendamos compositions raramente utilizaremos un managed resource solo.

Vamos a crear nuestro primer XRD.

  • El grupo es donde el Kind que vamos a crear abajo estará en la API de Kubernetes.
---
apiVersion: apiextensions.crossplane.io/v1 # Extendiendo por Crossplane ahora
kind: CompositeResourceDefinition
metadata:
name: hello.devsecops.puziol.com.br
spec:
group: devsecops.puziol.com.br
names:
kind: Hello
plural: hello
claimNames:
kind: HelloClain
plural: helloclaim
versions:
# Sigue las versiones como kubernetes hace, v1, v1beta1, v1alpha1, etc.
# Podemos declarar varias versiones para tener retrocompatibilidad y served la hará estar o no disponible en la api para ser referenciado

- name: v1alpha1
served: true
referenceable: true
schema:
openAPIV3Schema: {}

Podemos tener varias versiones, pero solamente una de ellas puede ser referenceable.

Schema que aún no fue mostrado es basado en openapi. Lo que hicimos hasta ahora fue abrir un espacio en la api, pero no tiene nada definido. La parte de schemas espera recibir alguna cosa en la request que será hecha, pero por ahora dejamos en blanco.

Para conocer mejor sobre ese yaml vamos en https://docs.crossplane.io/api.

Vamos a aplicar el manifiesto arriba.

❯ kubectl apply -f compositiondefinition.yaml
compositeresourcedefinition.apiextensions.crossplane.io/hello.devsecops.puziol.com.br created

# Vamos a buscar los custom resources que generó.
❯ kubectl api-resources| grep hello
hello devsecops.puziol.com.br/v1alpha1 false Hello
helloclaim devsecops.puziol.com.br/v1alpha1 true HelloClain

# Solo para confirmar que esto es de hecho un custom resource definition en kubernetes
❯ kubectl get customresourcedefinitions.apiextensions.k8s.io | grep hello
hello.devsecops.puziol.com.br 2025-03-19T11:58:19Z
helloclaim.devsecops.puziol.com.br 2025-03-19T11:58:19Z

De curiosidad si quieres ver esto expuesto vamos a hacer el siguiente comando

kubectl proxy

Solo un spoiler rápido, Hello es un recurso a nivel de clúster, y HelloClaim es el mismo recurso a nivel de namespace, pero hablaremos de eso después.

La api de kubernetes estará disponible en 127.0.0.1:8001 en tu navegador. El path fue creado...

alt text

Haciendo un curl para los los paths disponibles..

curl http://127.0.0.1:8001/apis/devsecops.puziol.com.br
{
"kind": "APIGroup",
"apiVersion": "v1",
"name": "devsecops.puziol.com.br",
"versions": [
{
"groupVersion": "devsecops.puziol.com.br/v1alpha1",
"version": "v1alpha1"
}
],
"preferredVersion": {
"groupVersion": "devsecops.puziol.com.br/v1alpha1",
"version": "v1alpha1"
}
}%

# Aquí ya podemos ver los recursos disponibles.
curl http://127.0.0.1:8001/apis/devsecops.puziol.com.br/v1alpha1
{
"kind": "APIResourceList",
"apiVersion": "v1",
"groupVersion": "devsecops.puziol.com.br/v1alpha1",
"resources": [
{
"name": "helloclaim",
"singularName": "helloclain",
"namespaced": true, ## A NIVEL DE NAMESPACE
"kind": "HelloClain",
"verbs": [
"delete",
"deletecollection",
"get",
"list",
"patch",
"create",
"update",
"watch"
],
"categories": [
"claim"
],
"storageVersionHash": "KaYrjAdZFTk="
},
{
"name": "helloclaim/status",
"singularName": "",
"namespaced": true,
"kind": "HelloClain",
"verbs": [
"get",
"patch",
"update"
]
},
{
"name": "hello",
"singularName": "hello",
"namespaced": false, ## A NIVEL DE CLUSTER
"kind": "Hello",
"verbs": [
"delete",
"deletecollection",
"get",
"list",
"patch",
"create",
"update",
"watch"
],
"categories": [
"composite"
],
"storageVersionHash": "kBh7PX4F5h4="
},
{
"name": "hello/status",
"singularName": "",
"namespaced": false,
"kind": "Hello",
"verbs": [
"get",
"patch",
"update"
]
}
]
}%

Y verificando nuestro recurso en sí tenemos.

❯ kubectl get compositeresourcedefinitions.apiextensions.crossplane.io
NAME ESTABLISHED OFFERED AGE
hello.devsecops.puziol.com.br True True 9m37s

## xrds o xrd también puede ser usado.
❯ kubectl get xrds
NAME ESTABLISHED OFFERED AGE
hello.devsecops.puziol.com.br True True 9m39s

La definición de esos recursos son fáciles de ver pues son detectables y podemos utilizar inclusive en los IDPs y en muchas otras herramientas.

❯ kubectl explain hello.devsecops.puziol.com.br --recursive
GROUP: devsecops.puziol.com.br
KIND: Hello
VERSION: v1alpha1

DESCRIPTION:
<empty>
FIELDS:
apiVersion <string>
kind <string>
metadata <ObjectMeta>
annotations <map[string]string>
creationTimestamp <string>
deletionGracePeriodSeconds <integer>
deletionTimestamp <string>
finalizers <[]string>
generateName <string>
generation <integer>
labels <map[string]string>
managedFields <[]ManagedFieldsEntry>
apiVersion <string>
fieldsType <string>
fieldsV1 <FieldsV1>
manager <string>
operation <string>
subresource <string>
time <string>
name <string>
namespace <string>
ownerReferences <[]OwnerReference>
apiVersion <string> -required-
blockOwnerDeletion <boolean>
controller <boolean>
kind <string> -required-
name <string> -required-
uid <string> -required-
resourceVersion <string>
selfLink <string>
uid <string>
spec <Object> -required-
claimRef <Object>
apiVersion <string> -required-
kind <string> -required-
name <string> -required-
namespace <string> -required-
compositionRef <Object>
name <string> -required-
compositionRevisionRef <Object>
name <string> -required-
compositionRevisionSelector <Object>
matchLabels <map[string]string> -required-
compositionSelector <Object>
matchLabels <map[string]string> -required-
compositionUpdatePolicy <string>
enum: Automatic, Manual
publishConnectionDetailsTo <Object>
configRef <Object>
name <string>
metadata <Object>
annotations <map[string]string>
labels <map[string]string>
type <string>
name <string> -required-
resourceRefs <[]Object>
apiVersion <string> -required-
kind <string> -required-
name <string>
writeConnectionSecretToRef <Object>
name <string> -required-
namespace <string> -required-
status <Object>
claimConditionTypes <[]string>
conditions <[]Object>
lastTransitionTime <string> -required-
message <string>
reason <string> -required-
status <string> -required-
type <string> -required-
connectionDetails <Object>
lastPublishedTime <string>

Vamos a crear un recurso del tipo Hello.

apiVersion: devsecops.puziol.com.br/v1alpha1
kind: Hello
metadata:
name: hello-test
spec: {} # Vamos a iniciar vacío
❯ kubectl apply -f hellocomposition.yaml
hello.devsecops.puziol.com.br/hello-test created

❯ k get hello.devsecops.puziol.com.br
NAME SYNCED READY COMPOSITION AGE
hello-test False 6s

No está sincronizado porque el controller de Crossplane no consiguió ni comenzar a trabajar en él, después de todo solo hicimos declaraciones que no informan a Crossplane lo que debe hacer y eso era lo esperado. La información que falta es cuál composition debería disparar, pero aún no hicimos ninguna.

apiVersion: devsecops.puziol.com.br/v1alpha1
kind: Hello
metadata:
name: hello-test
spec: {} # Vamos a iniciar vacío

Vamos a pensar en ese xrd.

---
apiVersion: apiextensions.crossplane.io/v1
kind: CompositeResourceDefinition
metadata:
name: sqls.devsecops.puziol.com.br
spec:
group: devsecops.puziol.com.br
names:
kind: SQL
plural: sqls
claimNames:
kind: SQLClaim
plural: sqlclaims
versions:
- name: v1alpha1
served: true
referenceable: true
schema:
openAPIV3Schema: {}

Si aplicásemos el manifiesto abajo, claro que en este momento no tenemos nadie respondiendo a esto, pero si tuviese, la composition que estuviese definida para responder iniciará.

apiVersion: devsecops.puziol.com.br/v1alpha1
kind: SQL
metadata:
name: database
spec: {}

Abajo tenemos el inicio de una composition y vamos a mostrar cómo ella sabe que necesita responder en el endpoint correcto.

---
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
name: postgresql
spec:
compositeTypeRef: # ESTO INDICA QUE ESTA COMPOSITION RESPONDERÁ CUANDO.
# ESTE SEA EL GROUP
apiVersion: devsecops.puziol.com.br/v1alpha1
# Y ESTE SEA EL KIND
kind: SQL
resources:
#.... continúa...

Pero, podríamos tener varias compositions respondiendo en el mismo group/kind (endpoint). Imagina que una composition creará la base de datos en AWS, y la otra en GCP.

Composition para GCP.

apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
name: gcp-postgresql
labels: # colocamos labels para apuntar diferencia una de la otra.
provider: gcp
db: postgresql
spec:
compositeTypeRef:
apiVersion: devsecops.puziol.com.br/v1alpha1
kind: SQL
resources: # para gcp
#.... continúa...

Composition para AWS.


```yaml
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
name: aws-postgresql
labels: # colocamos labels para apuntar diferencia una de la otra.
provider: aws
db: postgresql
spec:
compositeTypeRef:
apiVersion: devsecops.puziol.com.br/v1alpha1
kind: SQL
resources: # para aws
#.... continúa...

Cuando definamos un manifiesto que queremos una base SQL PostgreSQL en GCP haríamos así...

apiVersion: devsecops.puziol.com.br/v1alpha1
kind: SQL
metadata:
name: my-postgressql
spec:
#...
## DE LAS COMPOSITIONS QUE RESPONDEN VAMOS A SELECCIONAR LA QUE TIENE ESAS DOS LABELS
compositionSelector:
matchLabels:
provider: gcp
db: postgresql

Entonces la composition gcp-postgresql iniciaría.

No es posible hablar de composition sin hablar de XRD, pues una cosa está ligada a la otra y una cosa llama a la otra. Al hacer una llamada de API a través de la aplicación de manifiestos estamos haciendo para el XRD y podemos pasar muchos parámetros que pueden ser utilizados dentro de las compositions.

Observa que dije PUEDEN. Claro que si vamos a pedir parámetros queremos utilizar, pero no necesitaríamos si no quisiéramos.

Vamos a explorar un poco más el XRD para recibir parámetros y encaminar parámetros más tarde para las compositions. Si vamos a crear una base de datos podemos querer definir la versión, el tamaño, el nombre, y otras cosas.

apiVersion: apiextensions.crossplane.io/v1
kind: CompositeResourceDefinition
metadata:
name: sqls.devsecops.puziol.com.br
spec:
group: devsecops.puziol.com.br
names:
kind: SQL
plural: sqls
claimNames:
kind: SQLClaim
plural: sqlclaims
versions:
- name: v1alpha1
served: true
referenceable: true
schema: # Aquí ya vamos a definir el objeto de la request
openAPIV3Schema:
type: object
properties:
spec:
type: object # podría ser object, array, string, integer, number, boolean
# Creo que solo usar object será la mejor opción en 99% de los casos, para quedar más portable y permitir mejoras
# Todo object tiene el properties. Dentro de él podemos definir cualquier cosa.
properties:
id: # Queremos tener un ID,
type: string
description: Database ID
parameters:
type: object # Otro objeto dentro de este.
properties:
version:
description: The DB version depends on the DB type and versions available in the selected provider.
type: string
size:
description: "Supported sizes: small, medium, large"
type: string
# Estamos forzando que solamente estas son las entradas permitidas
enum:
- small
- medium
- large
default: small
databases:
# Crear servidor de base de datos no tiene sentido si no tener base de datos dentro, entonces vamos a pasar una lista.
description: The list of databases to create inside the DB server.
type: array
items:
type: string
# Si no quieres, podemos eliminar el schema.
schemas:
## Vamos a pasar un schema para cada uno de los bancos
description: Database schema. Atlas operator (https://atlasgo.io/integrations/kubernetes/operator) needs to be installed in the cluster. Leave empty if schema should NOT be applied.
type: array # Un array de objetos!
# Podríamos usar para inicializar correr comandos sql en la inicialización del database
# - database1
# sql: una string
# - database2
# sql: una string
# ...
items:
type: object
properties:
database:
description: The name of the database where to apply the schema.
type: string
sql:
description: The SQL to apply.
type: string
required:
- version
required:
- parameters

Podemos pensar que quedó faltando pasar un usuario, pero vamos a dejar que la composition resuelva eso con un usuario default y contraseña aleatoria.

Con esa definición arriba ¿cómo sería el objeto que estamos esperando recibir?

apiVersion: devsecops.puziol.com.br/v1alpha1
kind: SQLClaim
metadata:
name: teste-db
annotations:
organization: DevSecOps
spec:
id: teste-db
compositionSelector: # Queremos que la composition que responde con esas labels haga esto.
matchLabels:
provider: google
db: postgresql
parameters:
version: "13"
size: small
databases:
- db-01
- db-02
schemas:
- database: db-01
sql: |
create table videos (
id varchar(50) not null,
description text,
primary key (id)
);
create table comments (
id serial,
video_id varchar(50) not null,
description text not null,
primary key (id),
CONSTRAINT fk_videos FOREIGN KEY(video_id) REFERENCES videos(id)
);

Todos esos valores que pasamos serán encaminados a la composition correcta y podemos trabajar con ellos en el montaje de recursos. Es la misma cosa que pasar variables para un proyecto Terraform.

¿Y si no pasamos cuál composition queremos utilizar?

Podemos definir cuál composition será la default colocando esta annotation crossplane.io/is-default-composition: "true" en una de ellas.

Si dos tienen esta misma label de default, aquella que sea encontrada primero, es decir, la que alfabéticamente viene primero, será escogida. Lo mismo vale si ninguna es definida como default.

Teniendo una composition default, o solamente una composition que responde al XRD no necesitamos pasar el compositionSelector caso queramos.

Para saber cuáles las compositions responden al XRD es verificar el status del propio XRD, que puede contener referencias a las Compositions que lo usan.

kubectl describe xrd sqls.devsecops.puziol.com.br