Create a control plane project
Now that you have an Upbound account and the up CLI installed, you are ready to create a control plane.
In this quickstart, you will:
- Scaffold a control plane project
- Define your own resource abstraction and templatization
- See the changes immediately
This quickstart teaches how to use Crossplane to build workflows for templating resources and exposing them as simplified resource abstraction. If you just want to manage the lifecycle of resources in an external system through Crossplane and Kubernetes, read Manage external resources with providers
Prerequisites
This quickstart takes around 10 minutes to complete. You should be familiar with YAML or programming in Go, Python, or KCL.
Before beginning, make sure you have:
- The up CLI installed
- A Docker-compatible container runtime installed and running on your system
Create a control plane project
Crossplane works by letting you define new resource types in Kubernetes that invoke function pipelines to template and generate other resources. Just like any other software project, a control plane project is a source-level representation of your control plane.
Create a control plane project on your machine by running the following command:
up project init --scratch getting-started
This scaffolds a new project in a folder called getting-started
. Change your
current working directory to the project root folder:
cd getting-started
Deploy your control plane
In the root directory of your project, build and run your project by running the following:
up project run --local --ingress
This launches an instance of Upbound Crossplane on your machine, wrapped and deployed in a container. Upbound Crossplane comes bundled with a Web UI.
Define your own resource type
Customize your control plane by defining your own resource type.
Create an example instance of your custom resource type with:
up example generate \
--api-group platform.example.com \
--api-version v1alpha1 \
--kind WebApp\
--name my-app \
--scope namespace \
--namespace default
Open the project in your IDE of choice and replace the contents of the generated file
getting-started/examples/webapp/my-app.yaml
with the following:
apiVersion: platform.example.com/v1alpha1
kind: WebApp
metadata:
name: my-app
namespace: default
spec:
parameters:
image: nginx
port: 8080
replicas: 1
service:
enabled: true
ingress:
enabled: false
serviceAccount: default
resources:
requests:
memory: "64Mi"
cpu: "250m"
limits:
memory: "1Gi"
cpu: "1"
status:
availableReplicas: 1
url: "http://localhost:8080"
Next, generate the definition files needed by Crossplane with the following commands:
- Go Templates
- Python
- Go
- KCL
up xrd generate examples/webapp/my-app.yaml
up composition generate apis/webapps/definition.yaml
up function generate --language=go-templating compose-resources apis/webapps/composition.yaml
up dependency add --api k8s:v1.33.0
up xrd generate examples/webapp/my-app.yaml
up composition generate apis/webapps/definition.yaml
up function generate --language=python compose-resources apis/webapps/composition.yaml
up dependency add --api k8s:v1.33.0
up xrd generate examples/webapp/my-app.yaml
up composition generate apis/webapps/definition.yaml
up function generate --language=go compose-resources apis/webapps/composition.yaml
up dependency add --api k8s:v1.33.0
up xrd generate examples/webapp/my-app.yaml
up composition generate apis/webapps/definition.yaml
up function generate --language=kcl compose-resources apis/webapps/composition.yaml
up dependency add --api k8s:v1.33.0
You just created your own resource type called WebApp
. You generated a function
containing the logic Crossplane uses to determine what should happen when you
create the WebApp
.
To define a new resource type with Crossplane, you need to:
- create a CompositeResourceDefinition (XRD), which defines the API schema of your resource type
- create a Composition, which defines the implementation of that API schema.
- A Composition is a pipeline of functions, which contain the user-defined logic of your composition.
Open the function definition file at
getting-started/functions/compose-resources/
and replace the contents with the
following:
- Go Templates
- Python
- Go
- KCL
# code: language=yaml
# yaml-language-server: $schema=../../.up/json/models/index.schema.json
---
apiVersion: apps/v1
kind: Deployment
metadata:
annotations:
gotemplating.fn.crossplane.io/composition-resource-name: deployment
{{ if eq (.observed.resources.deployment | getResourceCondition "Available").Status "True" }}
gotemplating.fn.crossplane.io/ready: "True"
{{ end }}
name: {{ .observed.composite.resource.metadata.name }}
namespace: {{ .observed.composite.resource.metadata.namespace }}
labels:
app.kubernetes.io/name: {{ .observed.composite.resource.metadata.name }}
spec:
replicas: {{ .observed.composite.resource.spec.parameters.replicas }}
selector:
matchLabels:
app.kubernetes.io/name: {{ .observed.composite.resource.metadata.name }}
app: {{ .observed.composite.resource.metadata.name }}
strategy: {}
template:
metadata:
labels:
app.kubernetes.io/name: {{ .observed.composite.resource.metadata.name }}
app: {{ .observed.composite.resource.metadata.name }}
spec:
serviceAccountName: {{ .observed.composite.resource.spec.parameters.serviceAccount }}
containers:
- name: {{ .observed.composite.resource.metadata.name }}
image: {{ .observed.composite.resource.spec.parameters.image }}
imagePullPolicy: Always
ports:
- name: http
containerPort: {{ .observed.composite.resource.spec.parameters.port }}
protocol: TCP
resources:
requests:
memory: {{ .observed.composite.resource.spec.parameters.resources.requests.memory }}
cpu: {{ .observed.composite.resource.spec.parameters.resources.requests.cpu }}
limits:
memory: {{ .observed.composite.resource.spec.parameters.resources.limits.memory }}
cpu: {{ .observed.composite.resource.spec.parameters.resources.limits.cpu }}
restartPolicy: Always
status: {}
{{ if .observed.composite.resource.spec.parameters.ingress.enabled }}
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
annotations:
gotemplating.fn.crossplane.io/composition-resource-name: ingress
{{ if (get (getComposedResource . "ingress").status.loadBalancer.ingress 0).hostname }}
gotemplating.fn.crossplane.io/ready: "True"
{{ end }}
kubernetes.io/ingress.class: alb
alb.ingress.kubernetes.io/scheme: internet-facing
alb.ingress.kubernetes.io/target-type: ip
alb.ingress.kubernetes.io/healthcheck-path: /health
alb.ingress.kubernetes.io/listen-ports: '[{"HTTP": 80}]'
alb.ingress.kubernetes.io/target-group-attributes: stickiness.enabled=true,stickiness.lb_cookie.duration_seconds=60
name: {{ .observed.composite.resource.metadata.name }}
namespace: {{ .observed.composite.resource.metadata.namespace }}
spec:
rules:
- http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: {{ .observed.composite.resource.metadata.name }}
port:
number: 80
{{ end }}
{{ if .observed.composite.resource.spec.parameters.service.enabled }}
---
apiVersion: v1
kind: Service
metadata:
annotations:
gotemplating.fn.crossplane.io/composition-resource-name: service
{{ if (get (getComposedResource . "service").spec "clusterIP") }}
gotemplating.fn.crossplane.io/ready: "True"
{{ end }}
name: {{ .observed.composite.resource.metadata.name }}
namespace: {{ .observed.composite.resource.metadata.namespace }}
spec:
selector:
app: {{ .observed.composite.resource.metadata.name }}
ports:
- name: http
protocol: TCP
port: 80
targetPort: http
status:
loadBalancer: {}
{{ end }}
---
apiVersion: {{ .observed.composite.resource.apiVersion }}
kind: {{ .observed.composite.resource.kind }}
status:
{{ with $deployment := getComposedResource . "deployment" }}
availableReplicas: {{ $deployment.status.availableReplicas | default 0 }}
{{ else }}
availableReplicas: 0
{{ end }}
{{ with $ingress := getComposedResource . "ingress" }}
{{ with $hostname := (get $ingress.status.loadBalancer.ingress 0).hostname }}
url: {{ $hostname | quote }}
{{ else }}
url: ""
{{ end }}
{{ else }}
url: ""
{{ end }}
from crossplane.function import resource
from crossplane.function.proto.v1 import run_function_pb2 as fnv1
from .model.io.k8s.api.apps import v1 as appsv1
from .model.io.k8s.api.core import v1 as corev1
from .model.io.k8s.api.networking import v1 as networkingv1
from .model.com.example.platform.webapp import v1alpha1 as platformv1alpha1
from .model.io.k8s.apimachinery.pkg.apis.core.meta import v1 as coremetav1
def compose(req: fnv1.RunFunctionRequest, rsp: fnv1.RunFunctionResponse):
oxr = platformv1alpha1.WebApp(**req.observed.composite.resource)
ocds = req.observed.resources
# Create a Status object to collect updates
status = platformv1alpha1.Status()
deployment = appsv1.Deployment(
metadata=coremetav1.ObjectMeta(
name=oxr.metadata.name,
namespace=oxr.metadata.namespace,
labels={
"app.kubernetes.io/name": oxr.metadata.name
},
),
spec=appsv1.DeploymentSpec(
replicas=oxr.spec.parameters.replicas,
selector=coremetav1.LabelSelector(
matchLabels={
"app.kubernetes.io/name": oxr.metadata.name,
"app": oxr.metadata.name
}
),
template=corev1.PodTemplateSpec(
metadata=coremetav1.ObjectMeta(
labels={
"app.kubernetes.io/name": oxr.metadata.name,
"app": oxr.metadata.name
}
),
spec=corev1.PodSpec(
serviceAccountName=oxr.spec.parameters.serviceAccount,
containers=[
corev1.Container(
name=oxr.metadata.name,
image=oxr.spec.parameters.image,
imagePullPolicy="Always",
ports=[
corev1.ContainerPort(
name="http",
containerPort=int(oxr.spec.parameters.port),
protocol="TCP",
)
],
resources=corev1.ResourceRequirements(
requests={
"memory": oxr.spec.parameters.resources.requests.memory,
"cpu": oxr.spec.parameters.resources.requests.cpu
},
limits={
"memory": oxr.spec.parameters.resources.limits.memory,
"cpu": oxr.spec.parameters.resources.limits.cpu
}
)
)
],
restartPolicy="Always"
)
)
)
)
if "deployment" in ocds:
observed_deployment = appsv1.Deployment(**ocds["deployment"].resource)
if observed_deployment.status and observed_deployment.status.conditions:
for condition in observed_deployment.status.conditions:
if condition.type == "Available" and condition.status == "True":
rsp.desired.resources["deployment"].ready = True
break
resource.update(rsp.desired.resources["deployment"], deployment)
if oxr.spec.parameters.service and oxr.spec.parameters.service.enabled:
service = corev1.Service(
metadata=coremetav1.ObjectMeta(
name=oxr.metadata.name,
namespace=oxr.metadata.namespace,
),
spec=corev1.ServiceSpec(
selector={
"app": oxr.metadata.name
},
ports=[
corev1.ServicePort(
name="http",
protocol="TCP",
port=80,
targetPort="http"
)
]
)
)
if "service" in ocds:
observed_service = corev1.Service(**ocds["service"].resource)
if observed_service.spec and observed_service.spec.clusterIP:
rsp.desired.resources["service"].ready = True
resource.update(rsp.desired.resources["service"], service)
if oxr.spec.parameters.ingress and oxr.spec.parameters.ingress.enabled:
ingress = networkingv1.Ingress(
metadata=coremetav1.ObjectMeta(
name=oxr.metadata.name,
namespace=oxr.metadata.namespace,
annotations={
"kubernetes.io/ingress.class": "alb",
"alb.ingress.kubernetes.io/scheme": "internet-facing",
"alb.ingress.kubernetes.io/target-type": "ip",
"alb.ingress.kubernetes.io/healthcheck-path": "/health",
"alb.ingress.kubernetes.io/listen-ports": '[{"HTTP": 80}]',
"alb.ingress.kubernetes.io/target-group-attributes": "stickiness.enabled=true,stickiness.lb_cookie.duration_seconds=60"
}
),
spec=networkingv1.IngressSpec(
rules=[
networkingv1.IngressRule(
http=networkingv1.HTTPIngressRuleValue(
paths=[
networkingv1.HTTPIngressPath(
path="/",
pathType="Prefix",
backend=networkingv1.IngressBackend(
service=networkingv1.IngressServiceBackend(
name=oxr.metadata.name,
port=networkingv1.ServiceBackendPort(
number=80
)
)
)
)
]
)
)
]
)
)
if "ingress" in ocds:
observed_ingress = networkingv1.Ingress(**ocds["ingress"].resource)
if (observed_ingress.status and
observed_ingress.status.loadBalancer and
observed_ingress.status.loadBalancer.ingress and
len(observed_ingress.status.loadBalancer.ingress) > 0 and
observed_ingress.status.loadBalancer.ingress[0].hostname):
rsp.desired.resources["ingress"].ready = True
resource.update(rsp.desired.resources["ingress"], ingress)
# Set status with defaults
if "deployment" in ocds:
observed_deployment = appsv1.Deployment(**ocds["deployment"].resource)
status.availableReplicas = observed_deployment.status.availableReplicas if observed_deployment.status and observed_deployment.status.availableReplicas else 0
else:
status.availableReplicas = 0
if "ingress" in ocds:
observed_ingress = networkingv1.Ingress(**ocds["ingress"].resource)
status.url = (
observed_ingress.status.loadBalancer.ingress[0].hostname
if (observed_ingress.status and
observed_ingress.status.loadBalancer and
observed_ingress.status.loadBalancer.ingress and
len(observed_ingress.status.loadBalancer.ingress) > 0 and
observed_ingress.status.loadBalancer.ingress[0].hostname)
else ""
)
else:
status.url = ""
resource.update(rsp.desired.composite, {"status": status.model_dump(exclude_none=True)})
package main
import (
"context"
"encoding/json"
"dev.upbound.io/models/com/example/platform/v1alpha1"
appsv1 "dev.upbound.io/models/io/k8s/apps/v1"
coremetav1 "dev.upbound.io/models/io/k8s/core/meta/v1"
corev1 "dev.upbound.io/models/io/k8s/core/v1"
networkingv1 "dev.upbound.io/models/io/k8s/networking/v1"
resourcev1 "dev.upbound.io/models/io/k8s/resource/v1"
"github.com/crossplane/crossplane-runtime/pkg/logging"
"github.com/crossplane/function-sdk-go/errors"
fnv1 "github.com/crossplane/function-sdk-go/proto/v1"
"github.com/crossplane/function-sdk-go/request"
"github.com/crossplane/function-sdk-go/resource"
"github.com/crossplane/function-sdk-go/resource/composed"
"github.com/crossplane/function-sdk-go/response"
"k8s.io/utils/ptr"
)
// Function is your composition function.
type Function struct {
fnv1.UnimplementedFunctionRunnerServiceServer
log logging.Logger
}
// RunFunction runs the Function.
func (f *Function) RunFunction(_ context.Context, req *fnv1.RunFunctionRequest) (*fnv1.RunFunctionResponse, error) {
f.log.Info("Running function", "tag", req.GetMeta().GetTag())
rsp := response.To(req, response.DefaultTTL)
observedComposite, err := request.GetObservedCompositeResource(req)
if err != nil {
response.Fatal(rsp, errors.Wrap(err, "cannot get xr"))
return rsp, nil
}
observedComposed, err := request.GetObservedComposedResources(req)
if err != nil {
response.Fatal(rsp, errors.Wrap(err, "cannot get observed resources"))
return rsp, nil
}
var xr v1alpha1.WebApp
if err := convertViaJSON(&xr, observedComposite.Resource); err != nil {
response.Fatal(rsp, errors.Wrap(err, "cannot convert xr"))
return rsp, nil
}
params := xr.Spec.Parameters
if params == nil {
response.Fatal(rsp, errors.New("missing parameters"))
return rsp, nil
}
// We'll collect our desired composed resources into this map, then convert
// them to the SDK's types and set them in the response when we return.
desiredComposed := make(map[resource.Name]any)
defer func() {
desiredComposedResources, err := request.GetDesiredComposedResources(req)
if err != nil {
response.Fatal(rsp, errors.Wrap(err, "cannot get desired resources"))
return
}
for name, obj := range desiredComposed {
c := composed.New()
if err := convertViaJSON(c, obj); err != nil {
response.Fatal(rsp, errors.Wrapf(err, "cannot convert %s to unstructured", name))
return
}
dc := &resource.DesiredComposed{Resource: c}
// Check if this resource should be marked as ready
if c.GetAnnotations()["go.upbound.io/ready"] == "True" {
dc.Ready = resource.ReadyTrue
}
desiredComposedResources[name] = dc
}
if err := response.SetDesiredComposedResources(rsp, desiredComposedResources); err != nil {
response.Fatal(rsp, errors.Wrap(err, "cannot set desired resources"))
return
}
}()
// Create Deployment
deployment := &appsv1.Deployment{
APIVersion: ptr.To(appsv1.DeploymentAPIVersionAppsV1),
Kind: ptr.To(appsv1.DeploymentKindDeployment),
Metadata: &coremetav1.ObjectMeta{
Name: xr.Metadata.Name,
Namespace: xr.Metadata.Namespace,
Labels: &map[string]string{
"app.kubernetes.io/name": *xr.Metadata.Name,
},
},
Spec: &appsv1.DeploymentSpec{
Replicas: ptr.To(int32(*params.Replicas)),
Selector: &coremetav1.LabelSelector{
MatchLabels: &map[string]string{
"app.kubernetes.io/name": *xr.Metadata.Name,
"app": *xr.Metadata.Name,
},
},
// ToDo(haarchri): remove this
Strategy: &appsv1.IoK8SApiAppsV1DeploymentStrategy{},
Template: &corev1.PodTemplateSpec{
Metadata: &coremetav1.ObjectMeta{
Labels: &map[string]string{
"app.kubernetes.io/name": *xr.Metadata.Name,
"app": *xr.Metadata.Name,
},
},
Spec: &corev1.PodSpec{
ServiceAccountName: params.ServiceAccount,
Containers: &[]corev1.Container{{
Name: xr.Metadata.Name,
Image: params.Image,
ImagePullPolicy: ptr.To("Always"),
Ports: &[]corev1.ContainerPort{{
Name: ptr.To("http"),
ContainerPort: ptr.To(int32(*params.Port)),
Protocol: ptr.To("TCP"),
}},
Resources: &corev1.ResourceRequirements{
Requests: &map[string]resourcev1.Quantity{
"memory": *params.Resources.Requests.Memory,
"cpu": *params.Resources.Requests.CPU,
},
Limits: &map[string]resourcev1.Quantity{
"memory": *params.Resources.Limits.Memory,
"cpu": *params.Resources.Limits.CPU,
},
},
}},
RestartPolicy: ptr.To("Always"),
},
},
},
// ToDo(haarchri): remove this
Status: &appsv1.IoK8SApiAppsV1DeploymentStatus{},
}
// Check if deployment is ready
observedDeployment, ok := observedComposed["deployment"]
if ok && observedDeployment.Resource != nil {
var obsDeployment appsv1.Deployment
if err := convertViaJSON(&obsDeployment, observedDeployment.Resource); err == nil {
if obsDeployment.Status != nil && obsDeployment.Status.Conditions != nil {
for _, c := range *obsDeployment.Status.Conditions {
if c.Type != nil && *c.Type == "Available" &&
c.Status != nil && *c.Status == "True" {
if deployment.Metadata.Annotations == nil {
deployment.Metadata.Annotations = &map[string]string{}
}
(*deployment.Metadata.Annotations)["go.upbound.io/ready"] = "True"
break
}
}
}
}
}
desiredComposed["deployment"] = deployment
// Create Service if enabled
if params.Service != nil && params.Service.Enabled != nil && *params.Service.Enabled {
service := &corev1.Service{
APIVersion: ptr.To(corev1.ServiceAPIVersionV1),
Kind: ptr.To(corev1.ServiceKindService),
Metadata: &coremetav1.ObjectMeta{
Name: xr.Metadata.Name,
Namespace: xr.Metadata.Namespace,
},
Spec: &corev1.ServiceSpec{
Selector: &map[string]string{
"app": *xr.Metadata.Name,
},
Ports: &[]corev1.ServicePort{{
Name: ptr.To("http"),
Protocol: ptr.To("TCP"),
Port: ptr.To(int32(80)),
TargetPort: ptr.To("http"),
}},
},
// ToDo(haarchri): remove this
Status: &corev1.ServiceStatus{
LoadBalancer: &corev1.LoadBalancerStatus{},
},
}
// Check if service is ready
observedService, ok := observedComposed["service"]
if ok && observedService.Resource != nil {
var obsService corev1.Service
if err := convertViaJSON(&obsService, observedService.Resource); err == nil {
if obsService.Spec != nil && obsService.Spec.ClusterIP != nil && *obsService.Spec.ClusterIP != "" {
if service.Metadata.Annotations == nil {
service.Metadata.Annotations = &map[string]string{}
}
(*service.Metadata.Annotations)["go.upbound.io/ready"] = "True"
}
}
}
desiredComposed["service"] = service
}
// Create Ingress if enabled
if params.Ingress != nil && params.Ingress.Enabled != nil && *params.Ingress.Enabled {
ingress := &networkingv1.Ingress{
APIVersion: ptr.To(networkingv1.IngressAPIVersionNetworkingK8SIoV1),
Kind: ptr.To(networkingv1.IngressKindIngress),
Metadata: &coremetav1.ObjectMeta{
Name: xr.Metadata.Name,
Namespace: xr.Metadata.Namespace,
Annotations: &map[string]string{
"kubernetes.io/ingress.class": "alb",
"alb.ingress.kubernetes.io/scheme": "internet-facing",
"alb.ingress.kubernetes.io/target-type": "ip",
"alb.ingress.kubernetes.io/healthcheck-path": "/health",
"alb.ingress.kubernetes.io/listen-ports": `[{"HTTP": 80}]`,
"alb.ingress.kubernetes.io/target-group-attributes": "stickiness.enabled=true,stickiness.lb_cookie.duration_seconds=60",
},
},
Spec: &networkingv1.IngressSpec{
Rules: &[]networkingv1.IngressRule{{
HTTP: &networkingv1.HTTPIngressRuleValue{
Paths: &[]networkingv1.HTTPIngressPath{{
Path: ptr.To("/"),
PathType: ptr.To("Prefix"),
Backend: &networkingv1.IngressBackend{
Service: &networkingv1.IngressServiceBackend{
Name: xr.Metadata.Name,
Port: &networkingv1.ServiceBackendPort{
Number: ptr.To(int32(80)),
},
},
},
}},
},
}},
},
}
// Check if ingress is ready
observedIngress, ok := observedComposed["ingress"]
if ok && observedIngress.Resource != nil {
var obsIngress networkingv1.Ingress
if err := convertViaJSON(&obsIngress, observedIngress.Resource); err == nil {
if obsIngress.Status != nil && obsIngress.Status.LoadBalancer != nil &&
obsIngress.Status.LoadBalancer.Ingress != nil && len(*obsIngress.Status.LoadBalancer.Ingress) > 0 {
firstIngress := (*obsIngress.Status.LoadBalancer.Ingress)[0]
if firstIngress.Hostname != nil && *firstIngress.Hostname != "" {
if ingress.Metadata.Annotations == nil {
ingress.Metadata.Annotations = &map[string]string{}
}
(*ingress.Metadata.Annotations)["go.upbound.io/ready"] = "True"
}
}
}
}
desiredComposed["ingress"] = ingress
}
// Update XR status
desiredXR, err := request.GetDesiredCompositeResource(req)
if err != nil {
response.Fatal(rsp, errors.Wrap(err, "cannot get desired composite resource"))
return rsp, nil
}
// Convert desired XR to WebApp
var desiredWebApp v1alpha1.WebApp
desiredWebApp.APIVersion = ptr.To(v1alpha1.WebAppAPIVersionplatformExampleComV1Alpha1)
desiredWebApp.Kind = ptr.To(v1alpha1.WebAppKindWebApp)
if err := convertViaJSON(&desiredWebApp, desiredXR.Resource); err != nil {
response.Fatal(rsp, errors.Wrap(err, "cannot convert desired xr"))
return rsp, nil
}
// Update status fields
if desiredWebApp.Status == nil {
desiredWebApp.Status = &v1alpha1.WebAppStatus{}
}
// Set deployment conditions
if observedDeployment, ok := observedComposed["deployment"]; ok && observedDeployment.Resource != nil {
var obsDeployment appsv1.Deployment
if err := convertViaJSON(&obsDeployment, observedDeployment.Resource); err == nil {
if obsDeployment.Status != nil {
if obsDeployment.Status.AvailableReplicas != nil {
desiredWebApp.Status.AvailableReplicas = ptr.To(float32(*obsDeployment.Status.AvailableReplicas))
} else {
// Set default value when no available replicas
desiredWebApp.Status.AvailableReplicas = ptr.To(float32(0))
}
} else {
// Set defaults when status is nil
desiredWebApp.Status.AvailableReplicas = ptr.To(float32(0))
}
}
} else {
// Set defaults when deployment doesn't exist
desiredWebApp.Status.AvailableReplicas = ptr.To(float32(0))
}
// Set ingress URL
if observedIngress, ok := observedComposed["ingress"]; ok && observedIngress.Resource != nil {
var obsIngress networkingv1.Ingress
if err := convertViaJSON(&obsIngress, observedIngress.Resource); err == nil {
if obsIngress.Status != nil && obsIngress.Status.LoadBalancer != nil &&
obsIngress.Status.LoadBalancer.Ingress != nil && len(*obsIngress.Status.LoadBalancer.Ingress) > 0 {
firstIngress := (*obsIngress.Status.LoadBalancer.Ingress)[0]
if firstIngress.Hostname != nil {
desiredWebApp.Status.URL = firstIngress.Hostname
} else {
// Set empty string when hostname is nil
desiredWebApp.Status.URL = ptr.To("")
}
} else {
// Set empty string when no load balancer ingress
desiredWebApp.Status.URL = ptr.To("")
}
} else {
// Set empty string when conversion fails
desiredWebApp.Status.URL = ptr.To("")
}
} else {
// Set empty string when ingress doesn't exist
desiredWebApp.Status.URL = ptr.To("")
}
// Convert back to unstructured
if err := convertViaJSON(desiredXR.Resource, &desiredWebApp); err != nil {
response.Fatal(rsp, errors.Wrap(err, "cannot convert desired webapp back to unstructured"))
return rsp, nil
}
if err := response.SetDesiredCompositeResource(rsp, desiredXR); err != nil {
response.Fatal(rsp, errors.Wrap(err, "cannot set desired composite resource"))
return rsp, nil
}
return rsp, nil
}
func convertViaJSON(to, from any) error {
bs, err := json.Marshal(from)
if err != nil {
return err
}
return json.Unmarshal(bs, to)
}
import models.io.k8s.api.apps.v1 as appsv1
import models.io.k8s.api.core.v1 as corev1
import models.io.k8s.api.networking.v1 as networkingv1
import models.com.example.platform.v1alpha1 as platformv1alpha1
oxr = platformv1alpha1.WebApp{**option("params").oxr} # observed claim
_ocds = option("params").ocds # observed composed resources
_dxr = option("params").dxr # desired composite resource
dcds = option("params").dcds # desired composed resources
_metadata = lambda name: str -> any {
{ annotations = { "krm.kcl.dev/composition-resource-name" = name }}
}
_desired_deployment = appsv1.Deployment{
metadata: _metadata("deployment") | {
name: oxr.metadata.name
namespace: oxr.metadata.namespace
labels: {
"app.kubernetes.io/name": oxr.metadata.name
}
}
spec: {
replicas: int(oxr.spec.parameters.replicas)
selector: {
matchLabels: {
"app.kubernetes.io/name": oxr.metadata.name
app: oxr.metadata.name
}
}
template: {
metadata: {
labels: {
"app.kubernetes.io/name": oxr.metadata.name
app: oxr.metadata.name
}
}
spec: {
serviceAccountName: oxr.spec.parameters.serviceAccount
containers: [{
name: oxr.metadata.name
image: oxr.spec.parameters.image
imagePullPolicy: "Always"
ports: [{
name: "http"
containerPort: int(oxr.spec.parameters.port)
protocol: "TCP"
}]
resources: {
requests: {
memory: oxr.spec.parameters.resources.requests.memory
cpu: oxr.spec.parameters.resources.requests.cpu
}
limits: {
memory: oxr.spec.parameters.resources.limits.memory
cpu: oxr.spec.parameters.resources.limits.cpu
}
}
}]
restartPolicy: "Always"
}
}
}
}
observed_deployment = option("params").ocds["deployment"]?.Resource
if any_true([c.type == "Available" and c.status == "True" for c in observed_deployment?.status?.conditions or []]):
_desired_deployment.metadata.annotations["krm.kcl.dev/ready"] = "True"
if oxr.spec.parameters.service.enabled:
_desired_service = corev1.Service{
metadata: _metadata("service") | {
name: oxr.metadata.name
namespace: oxr.metadata.namespace
}
spec: {
selector: {
app: oxr.metadata.name
}
ports: [{
name: "http"
protocol: "TCP"
port: 80
targetPort: "http"
}]
}
}
observed_service = option("params").ocds["service"]?.Resource
if observed_service?.spec?.clusterIP:
_desired_service.metadata.annotations["krm.kcl.dev/ready"] = "True"
if oxr.spec.parameters.ingress.enabled:
_desired_ingress = networkingv1.Ingress{
metadata: _metadata("ingress") | {
name: oxr.metadata.name
namespace: oxr.metadata.namespace
annotations: {
"kubernetes.io/ingress.class": "alb"
"alb.ingress.kubernetes.io/scheme": "internet-facing"
"alb.ingress.kubernetes.io/target-type": "ip"
"alb.ingress.kubernetes.io/healthcheck-path": "/health"
"alb.ingress.kubernetes.io/listen-ports": '[{"HTTP": 80}]'
"alb.ingress.kubernetes.io/target-group-attributes": "stickiness.enabled=true,stickiness.lb_cookie.duration_seconds=60"
}
}
spec: {
rules: [{
http: {
paths: [{
path: "/"
pathType: "Prefix"
backend: {
service: {
name: oxr.metadata.name
port: {
number: 80
}
}
}
}]
}
}]
}
}
observed_ingress = option("params").ocds["ingress"]?.Resource
if observed_ingress?.status?.loadBalancer?.ingress?[0]?.hostname:
_desired_ingress.metadata.annotations["krm.kcl.dev/ready"] = "True"
_desired_xr = {
**option("params").dxr
status.availableReplicas = observed_deployment?.status?.availableReplicas or 0
status.url = observed_ingress?.status?.loadBalancer?.ingress?[0]?.hostname or ""
}
items = [
_desired_deployment,
_desired_service,
_desired_ingress,
_desired_xr
]
Deploy the changes you made to your control plane:
up project run --local --ingress
The project run
command builds and deploys any changes. If you don't have a control plane running yet, it creates one, otherwise it'll target your existing control plane.
Use the custom resource
Your control plane now understands WebApp resources. Create a WebApp:
kubectl apply -f examples/webapp/my-app.yaml
Check that the WebApp is ready:
kubectl get -f examples/webapp/my-app.yaml
NAME SYNCED READY COMPOSITION AGE
my-app True True app-yaml 56s
Observe the Deployment and Service Crossplane created when you created the WebApp:
kubectl get deploy,service -l crossplane.io/composite=my-app
NAME READY UP-TO-DATE AVAILABLE AGE
deployment.apps/my-app-2r2rk 2/2 2 2 11m
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
service/my-app-xfkzg ClusterIP 10.96.148.56 <none> 8080/TCP 11m
Next steps
Now that you know the basics of building with Upbound, extend your WebApp custom resource type with an AI-augmented operation to detect and remediate issues that occur when running app workloads on Kubernetes. Read Create an AI-augmented operation.