Build a Typed Crossplane v2 API
Build a typed Crossplane v2 API that validates and deploys app environments.
Introduction
30 Second Summary
A simple request to run an app can bounce between people for hours. Vague details also leave room for unsafe defaults to slip through.
In this project, you will build a typed ApplicationEnvironment API on a local Kubernetes cluster using Crossplane 2.4.0. A separate staleness check will show why successful admission cannot prove that an assistant used the current Crossplane pattern.
What You'll Build
One small YAML request passes Kubernetes admission before becoming a running NGINX Deployment paired with an internal Service.
By the end of this project, you'll have:
- A namespaced ApplicationEnvironment API that refuses one bad request for each of its four field rules before reconciliation starts.
- A live environment where queue-api reaches both Synced=True plus Ready=True inside a measured 60-second window. Its provider Objects create a Deployment through workloads plus a Service through networking.
- A deterministic evidence pack that records four field-named admission refusals plus the exact Namespaced scope. It also compares two stale-construct totals through a numeric difference.
- Secret Mission: Prove with measurements that successful Kubernetes admission cannot confirm whether an assistant selected the current Crossplane v2 pattern.
Before We Start
Before We Start...
This checkpoint fixes the project purpose before any cluster work begins: build a namespaced Crossplane v2 ApplicationEnvironment API whose CEL rules reject invalid application, image, port, and replica values at Kubernetes admission. A separate live scope check confirms the current Namespaced pattern because admission only validates request values before reconciliation.
Start the Local Control Plane
A repeatable API experiment depends on every learner testing against the same local control plane. Version drift would make later admission results difficult to compare.
You’ll verify Docker plus the pinned command-line tools. Then kind creates a local Kubernetes 1.36.4 cluster before Helm installs Crossplane 2.4.0.
In this step, get ready to:
- Verify the local toolchain and prepare consistent line endings.
- Create the pinned kind cluster and install Crossplane.
- Install the provider packages and configure namespace-scoped permissions.
Verify the toolchain and line endings
The control plane relies on several local tools working together. The version checks below expose missing commands before they can interrupt the cluster build.
Check Docker Desktop licensing
Docker Desktop requires a paid subscription for government entities and some larger organizations. Confirm that your organization permits its use before continuing.
Docker Engine on Linux remains open source. An approved container runtime avoids a licensing surprise later.
- Select PowerShell as your shell on Windows.
- Select a POSIX shell on macOS or Linux.
- Check the container engine and local tool versions by running:
docker version
kind version
kubectl version --client
helm version
git --version
What do these checks prove?
- The Docker check proves that the client can reach a running container engine.
- The kind result must report 0.33.0.
- The kubectl result must report 1.37.0.
- The Helm result must report 3.21.4.
- The Git result confirms that the repository client is available from your shell.
✔️ Docker works and versions match
Your local toolchain is ready. The pinned clients can now create and inspect the same control plane used throughout this project.
- Keep the terminal open for the cluster commands later in this step.
ⓧ A version is wrong or Docker is stopped
Run only the command for the failed check. Each client command replaces the existing binary with the project’s pinned version.
macOS
- Start Docker Desktop by running this command:
open -a Docker
- Replace kind with version 0.33.0 by running:
[ "$(uname -m)" = "x86_64" ] && curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.33.0/kind-darwin-amd64
[ "$(uname -m)" = "arm64" ] && curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.33.0/kind-darwin-arm64
chmod +x ./kind
sudo mkdir -p /usr/local/bin
sudo mv ./kind /usr/local/bin/kind
- Replace kubectl with version 1.37.0 by running:
[ "$(uname -m)" = "x86_64" ] && KUBECTL_ARCH=amd64
[ "$(uname -m)" = "arm64" ] && KUBECTL_ARCH=arm64
curl -LO "https://dl.k8s.io/release/v1.37.0/bin/darwin/${KUBECTL_ARCH}/kubectl"
curl -LO "https://dl.k8s.io/release/v1.37.0/bin/darwin/${KUBECTL_ARCH}/kubectl.sha256"
echo "$(cat kubectl.sha256) kubectl" | shasum -a 256 --check
chmod +x ./kubectl
sudo mv ./kubectl /usr/local/bin/kubectl
rm kubectl.sha256
- Replace Helm with version 3.21.4 by running:
[ "$(uname -m)" = "x86_64" ] && HELM_ARCH=amd64
[ "$(uname -m)" = "arm64" ] && HELM_ARCH=arm64
curl -LO "https://get.helm.sh/helm-v3.21.4-darwin-${HELM_ARCH}.tar.gz"
tar -xzf "helm-v3.21.4-darwin-${HELM_ARCH}.tar.gz"
sudo mv "darwin-${HELM_ARCH}/helm" /usr/local/bin/helm
rm -rf "darwin-${HELM_ARCH}" "helm-v3.21.4-darwin-${HELM_ARCH}.tar.gz"
- Repeat the five checks by running:
docker version
kind version
kubectl version --client
helm version
git --version
You should see a running Docker engine, kind 0.33.0, kubectl 1.37.0, Helm 3.21.4, and a Git version.
Windows
- Start Docker Desktop from its per-user or all-users installation by running:
$dockerDesktop = if (Test-Path "$env:LOCALAPPDATA\Programs\DockerDesktop\Docker Desktop.exe") {
"$env:LOCALAPPDATA\Programs\DockerDesktop\Docker Desktop.exe"
} else {
"$env:ProgramFiles\Docker\Docker\Docker Desktop.exe"
}
Start-Process $dockerDesktop
- Prepare a command folder on your PATH by running:
$tools = "$HOME\bin"
New-Item -ItemType Directory -Force -Path $tools | Out-Null
$userPath = [Environment]::GetEnvironmentVariable("Path", "User")
if ($userPath -notlike "*$tools*") {
[Environment]::SetEnvironmentVariable("Path", "$tools;$userPath", "User")
}
$env:Path = "$tools;$env:Path"
- Replace kind with version 0.33.0 by running:
curl.exe -Lo kind.exe https://kind.sigs.k8s.io/dl/v0.33.0/kind-windows-amd64
Move-Item -Force .\kind.exe "$tools\kind.exe"
- Replace kubectl with version 1.37.0 by running:
curl.exe -Lo kubectl.exe "https://dl.k8s.io/release/v1.37.0/bin/windows/amd64/kubectl.exe"
curl.exe -Lo kubectl.exe.sha256 "https://dl.k8s.io/v1.37.0/bin/windows/amd64/kubectl.exe.sha256"
if ((Get-FileHash -Algorithm SHA256 .\kubectl.exe).Hash -ne (Get-Content .\kubectl.exe.sha256)) {
throw "kubectl checksum verification failed"
}
Move-Item -Force .\kubectl.exe "$tools\kubectl.exe"
Remove-Item .\kubectl.exe.sha256
- Replace Helm with version 3.21.4 by running:
curl.exe -Lo helm.zip "https://get.helm.sh/helm-v3.21.4-windows-amd64.zip"
Expand-Archive -Force .\helm.zip .\helm-package
Move-Item -Force .\helm-package\windows-amd64\helm.exe "$tools\helm.exe"
Remove-Item -Recurse -Force .\helm-package, .\helm.zip
- Repeat the five checks by running:
docker version
kind version
kubectl version --client
helm version
git --version
You should see a running Docker engine, kind 0.33.0, kubectl 1.37.0, Helm 3.21.4, and a Git version.
Linux
- Start Docker Engine by running:
sudo systemctl start docker
- Replace kind with version 0.33.0 by running:
[ "$(uname -m)" = "x86_64" ] && curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.33.0/kind-linux-amd64
[ "$(uname -m)" = "aarch64" ] && curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.33.0/kind-linux-arm64
chmod +x ./kind
sudo mv ./kind /usr/local/bin/kind
- Replace kubectl with version 1.37.0 by running:
[ "$(uname -m)" = "x86_64" ] && KUBECTL_ARCH=amd64
[ "$(uname -m)" = "aarch64" ] && KUBECTL_ARCH=arm64
curl -LO "https://dl.k8s.io/release/v1.37.0/bin/linux/${KUBECTL_ARCH}/kubectl"
curl -LO "https://dl.k8s.io/release/v1.37.0/bin/linux/${KUBECTL_ARCH}/kubectl.sha256"
echo "$(cat kubectl.sha256) kubectl" | sha256sum --check
sudo install -o root -g root -m 0755 kubectl /usr/local/bin/kubectl
rm kubectl kubectl.sha256
- Replace Helm with version 3.21.4 by running:
[ "$(uname -m)" = "x86_64" ] && HELM_ARCH=amd64
[ "$(uname -m)" = "aarch64" ] && HELM_ARCH=arm64
curl -LO "https://get.helm.sh/helm-v3.21.4-linux-${HELM_ARCH}.tar.gz"
tar -xzf "helm-v3.21.4-linux-${HELM_ARCH}.tar.gz"
sudo mv "linux-${HELM_ARCH}/helm" /usr/local/bin/helm
rm -rf "linux-${HELM_ARCH}" "helm-v3.21.4-linux-${HELM_ARCH}.tar.gz"
- Repeat the five checks by running:
docker version
kind version
kubectl version --client
helm version
git --version
You should see a running Docker engine, kind 0.33.0, kubectl 1.37.0, Helm 3.21.4, and a Git version.
ⓧ A command is missing
Install each missing tool from its official distribution. The commands below pin the clients used by this project.
macOS
- Download Docker.dmg from the official Docker Desktop page.
- Install and start Docker Desktop from your Downloads folder by running:
cd ~/Downloads
sudo hdiutil attach Docker.dmg
sudo /Volumes/Docker/Docker.app/Contents/MacOS/install
sudo hdiutil detach /Volumes/Docker
open -a Docker
- Install kind 0.33.0 by running:
[ "$(uname -m)" = "x86_64" ] && curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.33.0/kind-darwin-amd64
[ "$(uname -m)" = "arm64" ] && curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.33.0/kind-darwin-arm64
chmod +x ./kind
sudo mkdir -p /usr/local/bin
sudo mv ./kind /usr/local/bin/kind
- Install kubectl 1.37.0 by running:
[ "$(uname -m)" = "x86_64" ] && KUBECTL_ARCH=amd64
[ "$(uname -m)" = "arm64" ] && KUBECTL_ARCH=arm64
curl -LO "https://dl.k8s.io/release/v1.37.0/bin/darwin/${KUBECTL_ARCH}/kubectl"
curl -LO "https://dl.k8s.io/release/v1.37.0/bin/darwin/${KUBECTL_ARCH}/kubectl.sha256"
echo "$(cat kubectl.sha256) kubectl" | shasum -a 256 --check
chmod +x ./kubectl
sudo mv ./kubectl /usr/local/bin/kubectl
rm kubectl.sha256
- Install Helm 3.21.4 by running:
[ "$(uname -m)" = "x86_64" ] && HELM_ARCH=amd64
[ "$(uname -m)" = "arm64" ] && HELM_ARCH=arm64
curl -LO "https://get.helm.sh/helm-v3.21.4-darwin-${HELM_ARCH}.tar.gz"
tar -xzf "helm-v3.21.4-darwin-${HELM_ARCH}.tar.gz"
sudo mv "darwin-${HELM_ARCH}/helm" /usr/local/bin/helm
rm -rf "darwin-${HELM_ARCH}" "helm-v3.21.4-darwin-${HELM_ARCH}.tar.gz"
- Start the macOS Git installer by running this command. Select Install when the system prompt opens:
xcode-select --install
- Repeat the five checks after each installer finishes by running:
docker version
kind version
kubectl version --client
helm version
git --version
You should see a running Docker engine, kind 0.33.0, kubectl 1.37.0, Helm 3.21.4, and a Git version.
Windows
- Download Docker Desktop Installer.exe from the official Docker Desktop page.
- Install Docker Desktop for your user account by running this command from your Downloads folder:
Set-Location "$HOME\Downloads"
Start-Process '.\Docker Desktop Installer.exe' -Wait -ArgumentList 'install', '--user'
- Start Docker Desktop by running:
Start-Process "$env:LOCALAPPDATA\Programs\DockerDesktop\Docker Desktop.exe"
- Prepare a command folder on your PATH by running:
$tools = "$HOME\bin"
New-Item -ItemType Directory -Force -Path $tools | Out-Null
$userPath = [Environment]::GetEnvironmentVariable("Path", "User")
if ($userPath -notlike "*$tools*") {
[Environment]::SetEnvironmentVariable("Path", "$tools;$userPath", "User")
}
$env:Path = "$tools;$env:Path"
- Install kind 0.33.0 by running:
curl.exe -Lo kind.exe https://kind.sigs.k8s.io/dl/v0.33.0/kind-windows-amd64
Move-Item -Force .\kind.exe "$tools\kind.exe"
Get-Command kind
kind version
PowerShell still cannot find kind?
- Close every PowerShell window.
- Press the Windows key to open system search.
- Type PowerShell.
- Press Enter to open a new terminal.
- Test the installed executable directly by running:
& "$HOME\bin\kind.exe" version
If the direct check reports kind 0.33.0, the installation worked. Refresh the current terminal’s path by running:
$env:Path = "$HOME\bin;$env:Path"
kind version
Still stuck? Help me diagnose why PowerShell cannot find kind..
- Install kubectl 1.37.0 by running:
curl.exe -Lo kubectl.exe "https://dl.k8s.io/release/v1.37.0/bin/windows/amd64/kubectl.exe"
curl.exe -Lo kubectl.exe.sha256 "https://dl.k8s.io/v1.37.0/bin/windows/amd64/kubectl.exe.sha256"
if ((Get-FileHash -Algorithm SHA256 .\kubectl.exe).Hash -ne (Get-Content .\kubectl.exe.sha256)) {
throw "kubectl checksum verification failed"
}
Move-Item -Force .\kubectl.exe "$tools\kubectl.exe"
Remove-Item .\kubectl.exe.sha256
- Install Helm 3.21.4 by running:
curl.exe -Lo helm.zip "https://get.helm.sh/helm-v3.21.4-windows-amd64.zip"
Expand-Archive -Force .\helm.zip .\helm-package
Move-Item -Force .\helm-package\windows-amd64\helm.exe "$tools\helm.exe"
Remove-Item -Recurse -Force .\helm-package, .\helm.zip
- Download Git for Windows from the official Git page. The download starts automatically.
- Run the downloaded Git installer from PowerShell:
Start-Process '[[GIT_INSTALLER="C:UsersyouDownloadsGit-2.x.x-64-bit.exe"]]' -Wait
- Repeat the five checks after each installer finishes by running:
docker version
kind version
kubectl version --client
helm version
git --version
You should see a running Docker engine, kind 0.33.0, kubectl 1.37.0, Helm 3.21.4, and a Git version.
Linux
Docker’s convenience script is intended for development environments. That fits this local control-plane project.
- Install Docker Engine from Docker’s official development script by running:
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
rm get-docker.sh
sudo usermod -aG docker "$USER"
- Start a shell with your new Docker group membership by running:
newgrp docker
- Install kind 0.33.0 by running:
[ "$(uname -m)" = "x86_64" ] && curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.33.0/kind-linux-amd64
[ "$(uname -m)" = "aarch64" ] && curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.33.0/kind-linux-arm64
chmod +x ./kind
sudo mv ./kind /usr/local/bin/kind
- Install kubectl 1.37.0 by running:
[ "$(uname -m)" = "x86_64" ] && KUBECTL_ARCH=amd64
[ "$(uname -m)" = "aarch64" ] && KUBECTL_ARCH=arm64
curl -LO "https://dl.k8s.io/release/v1.37.0/bin/linux/${KUBECTL_ARCH}/kubectl"
curl -LO "https://dl.k8s.io/release/v1.37.0/bin/linux/${KUBECTL_ARCH}/kubectl.sha256"
echo "$(cat kubectl.sha256) kubectl" | sha256sum --check
sudo install -o root -g root -m 0755 kubectl /usr/local/bin/kubectl
rm kubectl kubectl.sha256
- Install Helm 3.21.4 by running:
[ "$(uname -m)" = "x86_64" ] && HELM_ARCH=amd64
[ "$(uname -m)" = "aarch64" ] && HELM_ARCH=arm64
curl -LO "https://get.helm.sh/helm-v3.21.4-linux-${HELM_ARCH}.tar.gz"
tar -xzf "helm-v3.21.4-linux-${HELM_ARCH}.tar.gz"
sudo mv "linux-${HELM_ARCH}/helm" /usr/local/bin/helm
rm -rf "linux-${HELM_ARCH}" "helm-v3.21.4-linux-${HELM_ARCH}.tar.gz"
- Install Git with the package manager available on your distribution by running:
if command -v apt-get >/dev/null; then
sudo apt-get update
sudo apt-get install -y git-all
elif command -v dnf >/dev/null; then
sudo dnf install -y git-all
else
echo "Use your distribution's documented Git package."
fi
- Repeat the five checks by running:
docker version
kind version
kubectl version --client
helm version
git --version
You should see a running Docker engine, kind 0.33.0, kubectl 1.37.0, Helm 3.21.4, and a Git version.
Still failing a tool check?
Confirm that the installed command is available on your shell path. A newly installed command often becomes available after the terminal restarts.
For Docker, confirm that the engine is running before repeating the check.
Still stuck? Help me diagnose my local toolchain checks..
Your tools are ready. Create one workspace now so every file and command in the project stays in the same place.
- Create the typed-platform-api workspace on your Desktop and initialize it as a Git repository by using the tab for your operating system:
macOS
- Create and enter the workspace by running these commands:
cd ~/Desktop
mkdir -p typed-platform-api
cd typed-platform-api
git init
pwd
What does this setup do?
- The first command moves you to your Desktop.
- The next two commands create and enter the typed-platform-api folder.
- The git init command turns the folder into a Git repository.
- The final command prints your current location.
- Confirm that the printed path ends with Desktop/typed-platform-api.
Git also reports that it initialized a repository. Your workspace is ready for the project files.
Workspace command failed?
Confirm that your Desktop folder exists and that the terminal can write to it.
Still stuck? Help me create my typed-platform-api workspace on macOS..
Windows
- Create and enter the workspace by running these commands in PowerShell:
Set-Location -Path "$env:USERPROFILE\Desktop"
New-Item -Path ".\typed-platform-api" -ItemType Directory -Force | Out-Null
Set-Location -Path ".\typed-platform-api"
git init
Get-Location
What does this setup do?
- The first command moves you to your Desktop.
- The next two commands create and enter the typed-platform-api folder.
- The git init command turns the folder into a Git repository.
- The final command prints your current location.
- Confirm that the printed path ends with Desktop\typed-platform-api.
Git also reports that it initialized a repository. Your workspace is ready for the project files.
Workspace command failed?
Confirm that your Desktop folder exists and that PowerShell can write to it.
Still stuck? Help me create my typed-platform-api workspace on Windows..
Linux
- Create and enter the workspace by running these commands:
cd ~/Desktop
mkdir -p typed-platform-api
cd typed-platform-api
git init
pwd
What does this setup do?
- The first command moves you to your Desktop.
- The next two commands create and enter the typed-platform-api folder.
- The git init command turns the folder into a Git repository.
- The final command prints your current location.
- Confirm that the printed path ends with Desktop/typed-platform-api.
Git also reports that it initialized a repository. Your workspace is ready for the project files.
Workspace command failed?
Confirm that your Desktop folder exists and that the terminal can write to it.
Still stuck? Help me create my typed-platform-api workspace on Linux..
The editor does not affect the control plane. Choose one client for working with files, then open the typed-platform-api workspace you just created on your Desktop.
Visual Studio Code
Visual Studio Code provides a general-purpose editor for the repository. Its desktop installer supports macOS, Windows, and Linux.
macOS
- Open the official Visual Studio Code macOS setup guide.
- Download the .dmg for your Mac architecture.
- Open the downloaded .dmg file.
- Drag Visual Studio Code.app into the Applications folder.
- Press Cmd+Space to open system search.
- Type Visual Studio Code.
- Press Enter to launch the editor.
You should see the Visual Studio Code welcome screen.
Windows
- Open the official Visual Studio Code Windows setup guide.
- Download the recommended User Setup installer.
- Run the downloaded installer.
- Follow the installer prompts.
- Press the Windows key to open system search.
- Type Visual Studio Code.
- Press Enter to launch the editor.
You should see the Visual Studio Code welcome screen.
Linux
- Open the official Visual Studio Code Linux setup guide.
- Choose the package for your Linux distribution.
- Install the package using the documented method.
- Open your desktop application launcher.
- Search for Visual Studio Code.
- Launch the editor from the search result.
You should see the Visual Studio Code welcome screen.
Cursor
Cursor combines a code editor with built-in AI workflows. The desktop editor supports macOS, Windows, and Linux.
macOS
Cursor requires macOS 12 or later.
- Open the official Cursor quickstart.
- Download the native .dmg for your Mac architecture.
- Open the downloaded .dmg file.
- Drag Cursor into the Applications folder.
- Press Cmd+Space to open system search.
- Type Cursor.
- Press Enter to launch the editor.
You should see the Cursor welcome screen.
Windows
Cursor requires Windows 10 or later.
- Open the official Cursor quickstart.
- Download the native .exe installer.
- Run the downloaded installer.
- Follow the installer prompts.
- Press the Windows key to open system search.
- Type Cursor.
- Press Enter to launch the editor.
You should see the Cursor welcome screen.
Linux
- Open the official Cursor quickstart.
- Choose the recommended .deb or .rpm package for your distribution.
- Install the package using the documented package-manager method.
- Open your desktop application launcher.
- Search for Cursor.
- Launch the editor from the search result.
You should see the Cursor welcome screen.
Google Antigravity IDE
Google Antigravity IDE provides an agent-focused editor with a local workspace. The IDE supports eligible macOS, Windows, and Linux systems.
macOS
Google Antigravity IDE requires macOS 12 or later. Intel Macs are not supported.
- Open the official Google Antigravity IDE setup guide.
- Download the macOS installer from the linked download page.
- Open the downloaded installer.
- Follow the installation prompts.
- Press Cmd+Space to open system search.
- Type Antigravity.
- Press Enter to launch the IDE.
You should see the Google Antigravity IDE welcome screen.
Windows
Google Antigravity IDE requires 64-bit Windows 10 or later.
- Open the official Google Antigravity IDE setup guide.
- Download the Windows installer from the linked download page.
- Run the downloaded installer.
- Follow the installation prompts.
- Press the Windows key to open system search.
- Type Antigravity.
- Press Enter to launch the IDE.
You should see the Google Antigravity IDE welcome screen.
Linux
Google Antigravity IDE requires a 64-bit Linux distribution with glibc 2.28 or later and glibcxx 3.4.25 or later.
- Open the official Google Antigravity IDE setup guide.
- Download the Linux installer from the linked download page.
- Install the downloaded package using its documented method.
- Open your desktop application launcher.
- Search for Antigravity.
- Launch the IDE from the search result.
You should see the Google Antigravity IDE welcome screen.
The repository uses Git attributes to keep shell scripts on LF line endings. PowerShell scripts stay on CRLF line endings.
- Click File in your chosen editor's top menu.
- Click Open Folder.
- Select the supplied typed-platform-api repository folder on your Desktop.
- Click Open.
- Create .gitattributes at the top level of typed-platform-api from the file sidebar.
- Add the repository line-ending rules by pasting:
* text=auto
*.sh text eol=lf
*.ps1 text eol=crlf
*.yaml text eol=lf
*.yml text eol=lf
*.md text eol=lf
What do these rules control?
- The first rule lets Git detect text files automatically.
- Shell scripts use LF so they run consistently on macOS and Linux.
- PowerShell scripts use CRLF for the Windows path.
- YAML plus Markdown files use LF across every platform.
- Save .gitattributes.
- Confirm that .gitattributes appears at the top level of your editor's file sidebar.
Cannot see the attributes file?
Check that the filename starts with a full stop. Confirm that your editor opened the typed-platform-api folder instead of its parent folder.
Still stuck? Help me create the repository attributes file..
✔️ Awesome, I've got everything!
Great. Your repository now has explicit line-ending rules for every file type used in this project.
ⓧ I'd like to double check the full code
* text=auto
*.sh text eol=lf
*.ps1 text eol=crlf
*.yaml text eol=lf
*.yml text eol=lf
*.md text eol=lf
Compare every line with your saved .gitattributes file.
Create the pinned cluster and install Crossplane
A pinned node image makes the API server version reproducible. The digest prevents the cluster image from changing behind the same tag.
- Create kind.yaml at the top level of typed-platform-api from your editor's file sidebar.
- Define the control-plane node by pasting:
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
- role: control-plane
image: kindest/node:v1.36.4@sha256:099e049362a1526b2db71494e1947aae99bd16290d7c895f2b7ea312e3cbfaed
What does this cluster configuration do?
- The configuration creates one control-plane node for the local experiment.
- The pinned node image runs Kubernetes 1.36.4.
- The digest fixes the exact image content used by kind.
- Save kind.yaml.
- Confirm that kind.yaml appears beside .gitattributes in your editor's file sidebar.
Does the YAML look misaligned?
Confirm that the node entry is indented by two spaces. Confirm that its image line is indented by four spaces.
Still stuck? Help me check the indentation in kind.yaml.
✔️ Awesome, I've got everything!
Your cluster configuration now fixes the control plane to Kubernetes 1.36.4.
ⓧ I'd like to double check the full code
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
- role: control-plane
image: kindest/node:v1.36.4@sha256:099e049362a1526b2db71494e1947aae99bd16290d7c895f2b7ea312e3cbfaed
Compare the complete file with your saved kind.yaml configuration.
- Return to the terminal in the typed-platform-api repository.
- Create the pinned typed-platform-api cluster by running:
kind create cluster --name typed-platform-api --config kind.yaml --wait 60s
What does this command create?
kind starts one local container as the Kubernetes control-plane node. It also creates the kubeconfig context kind-typed-platform-api for later kubectl commands.
The first pull is the slowest part of this setup. Give the command its full 60s wait window after the node image becomes available.
- Confirm that the terminal reports a completed cluster creation.
Did cluster creation fail?
Confirm that Docker Desktop or Docker Engine is still running. On Windows, confirm that Docker Desktop is using Linux containers.
Check that the node image in kind.yaml matches the supplied file exactly.
Still stuck? Help me diagnose the kind cluster creation failure.
Helm installs Crossplane from its stable chart repository. This installation stays inside the local cluster and does not sign in to a cloud account.
- Add the stable Crossplane chart repository by running the commands below:
helm repo add crossplane-stable https://charts.crossplane.io/stable --force-update
helm repo update crossplane-stable
helm install crossplane crossplane-stable/crossplane --namespace crossplane-system --create-namespace --version 2.4.0 --wait --timeout 3m
What does this installation do?
- The first command records the official stable chart repository.
- The second command refreshes the chart index.
- The final command installs Crossplane 2.4.0 in crossplane-system.
- The wait flags keep the command active until the release is ready or the three-minute timeout is reached.
That is the core control plane installed. Crossplane can now accept provider packages and composition functions.
Did the Helm installation time out?
Confirm that the kind cluster is still running. Check that your network permits chart downloads plus container image pulls.
Repeat the installation after the downloads complete.
Still stuck? Help me diagnose the Crossplane Helm installation.
Install packages and target permissions
Crossplane needs one provider to manage Kubernetes objects. It also needs one composition function to turn a future application request into those managed objects.
Why provider-kubernetes here?
Crossplane 2.4.0 can compose native Kubernetes resources directly. This project retains provider-kubernetes because the exercise models a typed API in front of an inherited provider boundary.
The workloads and networking ProviderConfigs make routing visible while sharing one provider runtime.
- Create the bootstrap folder inside typed-platform-api from your editor's file sidebar.
- Create packages.yaml inside the bootstrap folder.
- Define the provider runtime plus both packages by pasting:
apiVersion: pkg.crossplane.io/v1beta1
kind: DeploymentRuntimeConfig
metadata:
name: provider-kubernetes
spec:
serviceAccountTemplate:
metadata:
name: provider-kubernetes
---
apiVersion: pkg.crossplane.io/v1
kind: Provider
metadata:
name: provider-kubernetes
spec:
package: xpkg.upbound.io/crossplane-contrib/provider-kubernetes:v1.3.1
runtimeConfigRef:
apiVersion: pkg.crossplane.io/v1beta1
kind: DeploymentRuntimeConfig
name: provider-kubernetes
---
apiVersion: pkg.crossplane.io/v1
kind: Function
metadata:
name: function-patch-and-transform
spec:
package: xpkg.upbound.io/crossplane-contrib/function-patch-and-transform:v0.10.10
What do these package resources do?
- The DeploymentRuntimeConfig gives the provider runtime the provider-kubernetes ServiceAccount.
- The Provider installs provider-kubernetes 1.3.1.
- The Function installs Function Patch and Transform 0.10.10.
- Save bootstrap/packages.yaml.
- Submit the package resources to the cluster by running:
kubectl apply -f bootstrap/packages.yaml
What does this apply command do?
kubectl submits the runtime configuration plus both package declarations to the API server. Crossplane then pulls each pinned package and starts its runtime.
- Wait for both package health conditions by running:
kubectl wait --for=condition=Healthy provider/provider-kubernetes --timeout=180s
kubectl wait --for=condition=Healthy function/function-patch-and-transform --timeout=180s
What do the health waits prove?
Each command waits for Crossplane to mark the installed package healthy. A successful return proves that the package was pulled and its active revision is working.
Package pulls can take up to three minutes on a fresh machine. Silence during an image download does not mean the process has stalled.
Is a package still unhealthy?
Confirm that the package strings in bootstrap/packages.yaml match the supplied versions exactly. Confirm that your network can reach the package registry.
Still stuck? Help me diagnose an unhealthy Crossplane package.
✔️ Awesome, I've got everything!
Both package waits succeeded. The provider runtime plus composition function are available to the local control plane.
ⓧ I'd like to double check the full code
apiVersion: pkg.crossplane.io/v1beta1
kind: DeploymentRuntimeConfig
metadata:
name: provider-kubernetes
spec:
serviceAccountTemplate:
metadata:
name: provider-kubernetes
---
apiVersion: pkg.crossplane.io/v1
kind: Provider
metadata:
name: provider-kubernetes
spec:
package: xpkg.upbound.io/crossplane-contrib/provider-kubernetes:v1.3.1
runtimeConfigRef:
apiVersion: pkg.crossplane.io/v1beta1
kind: DeploymentRuntimeConfig
name: provider-kubernetes
---
apiVersion: pkg.crossplane.io/v1
kind: Function
metadata:
name: function-patch-and-transform
spec:
package: xpkg.upbound.io/crossplane-contrib/function-patch-and-transform:v0.10.10
Compare the complete package file with your saved bootstrap/packages.yaml file.
The provider now needs a narrow target boundary. Namespace-scoped RBAC limits it to Deployment plus Service operations inside platform-demo.
- Create target.yaml inside the bootstrap folder.
- Add the namespace plus Role by pasting:
apiVersion: v1
kind: Namespace
metadata:
name: platform-demo
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: environment-object-manager
namespace: platform-demo
rules:
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: [""]
resources: ["services"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
What does this access boundary allow?
- The Namespace creates the isolated platform-demo target.
- The Role permits lifecycle operations for Deployments in that namespace.
- The same Role permits lifecycle operations for Services in that namespace.
- Save bootstrap/target.yaml.
- Create the namespace plus Role by running:
kubectl apply -f bootstrap/target.yaml
What should this first apply create?
The API server creates platform-demo plus its environment-object-manager Role. The terminal output confirms both resources were accepted.
🙋♀️ Did the first target apply fail?
Check the array quotes in the Role rules. Confirm that both rule entries remain under the rules key.
Still stuck? Help me fix the namespace and Role YAML.
- Append the RoleBinding below the existing Role in bootstrap/target.yaml by pasting:
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: environment-object-manager
namespace: platform-demo
subjects:
- kind: ServiceAccount
name: provider-kubernetes
namespace: crossplane-system
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: environment-object-manager
What does the RoleBinding connect?
The RoleBinding grants the namespace Role to the provider runtime’s provider-kubernetes ServiceAccount. The ServiceAccount remains in crossplane-system while its permissions apply only in platform-demo.
- Save bootstrap/target.yaml.
- Create the RoleBinding by running:
kubectl apply -f bootstrap/target.yaml
What should this second apply create?
The existing namespace plus Role remain configured. The terminal output also confirms the new environment-object-manager RoleBinding.
🙋♀️ Was the ServiceAccount rejected?
Confirm that the provider package reached a healthy condition first. Check that the subject namespace is crossplane-system.
Still stuck? Help me check the provider RoleBinding.
- Append the two namespaced ProviderConfigs below the RoleBinding by pasting:
---
apiVersion: kubernetes.m.crossplane.io/v1alpha1
kind: ProviderConfig
metadata:
name: workloads
namespace: platform-demo
spec:
credentials:
source: InjectedIdentity
---
apiVersion: kubernetes.m.crossplane.io/v1alpha1
kind: ProviderConfig
metadata:
name: networking
namespace: platform-demo
spec:
credentials:
source: InjectedIdentity
Why use two ProviderConfigs?
The workloads ProviderConfig becomes the route for the Deployment wrapper. The networking ProviderConfig becomes the route for the Service wrapper.
Injected identity tells both routes to use the provider runtime’s in-cluster ServiceAccount. The RoleBinding controls what that identity can change.
- Save bootstrap/target.yaml.
- Create both ProviderConfigs by running:
kubectl apply -f bootstrap/target.yaml
What should this final apply create?
The terminal output confirms the workloads and networking ProviderConfigs. Both live in platform-demo with injected identity credentials.
You now have two explicit provider routes behind one least-privilege runtime. That boundary is ready for the Deployment plus Service built later.
Were the ProviderConfigs rejected?
Confirm that provider-kubernetes 1.3.1 is healthy. Check that both ProviderConfigs use the namespaced API shown in the supplied file.
Still stuck? Help me diagnose the ProviderConfig apply failure.
✔️ Awesome, I've got everything!
The target namespace now has restricted permissions plus separate provider routes for workloads and networking.
ⓧ I'd like to double check the full code
apiVersion: v1
kind: Namespace
metadata:
name: platform-demo
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: environment-object-manager
namespace: platform-demo
rules:
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: [""]
resources: ["services"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: environment-object-manager
namespace: platform-demo
subjects:
- kind: ServiceAccount
name: provider-kubernetes
namespace: crossplane-system
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: environment-object-manager
---
apiVersion: kubernetes.m.crossplane.io/v1alpha1
kind: ProviderConfig
metadata:
name: workloads
namespace: platform-demo
spec:
credentials:
source: InjectedIdentity
---
apiVersion: kubernetes.m.crossplane.io/v1alpha1
kind: ProviderConfig
metadata:
name: networking
namespace: platform-demo
spec:
credentials:
source: InjectedIdentity
Compare the complete target file with your saved bootstrap/target.yaml file.
All current repository files now exist. Git can normalize them against the line-ending policy you created earlier.
- Apply the repository line-ending rules to the existing files by running:
git add --renormalize .
What does renormalization do?
Git rechecks tracked text files against .gitattributes. This keeps shell files on LF plus PowerShell files on CRLF across supported platforms.
- Confirm that your editor's source control view lists the normalized project files as staged changes.
Did Git reject the normalization command?
Confirm that the terminal is inside the supplied typed-platform-api repository. Confirm that .gitattributes is saved before repeating the command.
Still stuck? Help me diagnose Git line-ending normalization.
Before the final check, what do you expect the cluster command to confirm about the context plus both installed packages?
- Verify the control plane plus package health by running:
kubectl cluster-info --context kind-typed-platform-api
kubectl get providers
kubectl get functions
What does the final check prove?
- The cluster information confirms that kubectl can reach the kind-typed-platform-api context.
- The Provider table confirms provider-kubernetes is installed and healthy.
- The Function table confirms Function Patch and Transform is installed and healthy.
You should see a responding local cluster. The provider plus function should each report HEALTHY=True.
Your pinned control plane is ready for API design work. Next, you’ll freeze the contract before any platform implementation begins.
Freeze the Contract
Your pinned local Kubernetes control plane is healthy. Its Crossplane packages are ready to reconcile resources.
Starting implementation without fixed acceptance criteria would make later reversals expensive. The failure signals must be explicit before the build begins.
This step freezes one narrow platform contract. You will also preserve a constrained AI draft as evidence before proving that the requested API has nowhere typed to go yet.
In this step, get ready to:
- Freeze the acceptance criteria for one namespaced ApplicationEnvironment API.
- Review a constrained AI validation draft as evidence.
- Demonstrate the expected missing-API failure with a server-side dry run.
Freeze the design boundaries
The repository does not contain a prebuilt documentation pack. You will create it in two constrained groups so each result can be checked before the next prompt runs.
Documentation group 1: Freeze the core contract
The first group defines what the platform must do and what stays outside its boundary. The second group will use these reviewed decisions as its source.
Workspace access lets the client create the files directly. Approve changes only for the four documentation paths named in this prompt.
- Open a new conversation in your chosen AI client.
- Give the client access to this repository if it supports workspace editing.
- Create the four core contract documents by pasting this prompt:
You are an AI coding assistant working in a local repository.
Create only the four core contract documents listed below.
Use concise Markdown with concrete headings and checklists.
Treat every requirement as fixed. Do not invent versions, commands, fields, or extra scope.
Fixed project contract:
- Pin Crossplane 2.4.0 and use apiextensions.crossplane.io/v2 with an explicit Namespaced XRD.
- Define platform.example.org/v1alpha1 kind ApplicationEnvironment with default Composition application-environment.
- Require spec.application, spec.image, spec.port, and spec.replicas.
- Enforce a lowercase DNS-label application of at most 63 characters, an explicit non-latest image tag of at most 255 characters, port 1-65535, and replicas 1-3.
- A valid queue-api request uses nginx:1.30.4-alpine, port 80, one replica, and namespace platform-demo.
- The Composition uses Function Patch and Transform plus two provider-kubernetes Objects: a Deployment through workloads and a Service through networking. Both ProviderConfigs share one provider runtime and local cluster.
- Acceptance requires four field-named refusals, Synced=True and Ready=True within 60 seconds, a successful Deployment rollout, and Deployment recreation after deletion.
- Exclude cloud providers, a second abstraction, migrations, multi-tenancy, access-control products, a UI, and production claims.
- Use a 45-minute plan: setup 6, design 8, build 20 as two protected 10-minute slices, validation 7, and close-out 4.
- AI may propose CEL rules and test specimens. It must not author or edit the XRD or Composition during the protected build.
Create these files:
- docs/acceptance-criteria.md: measurable checks, field rules, success signals, and every exclusion.
- docs/decisions.md: the chosen scope, tools, provider boundary, shared-runtime routing, validation approach, composition mode, and human review gate.
- docs/architecture.md: request-to-admission-to-XRD-to-Composition-to-function-to-two-Object-to-Deployment-and-Service flow, with responsibility boundaries.
- docs/plan.md: the exact 45-minute allocation and two protected build slices.
Do not create platform manifests, requests, scripts, evidence files, implementation code, or any other documentation file.
If you can edit the workspace, create the files directly. Otherwise, return each file under its exact path heading.
What Does Group 1 Establish?
- The acceptance criteria define the measurable finish line.
- The decisions record why each boundary exists.
- The architecture traces responsibility through the complete reconciliation path.
- The plan protects the two implementation slices from documentation work.
- Review the proposed changes before accepting them if your AI client requests workspace permission.
- Create each returned path through your editor's file sidebar if the client cannot edit the repository.
- Save all four documents.
- Confirm that docs/acceptance-criteria.md, docs/decisions.md, docs/architecture.md, and docs/plan.md appear in the file sidebar.
- Confirm that docs/acceptance-criteria.md names all four fields, all four validation rules, every success signal, and every exclusion.
- Confirm that docs/decisions.md records the namespaced Crossplane v2 choice plus the retained provider boundary.
- Confirm that docs/architecture.md traces one request through both provider-kubernetes Objects to a Deployment and Service.
- Confirm that docs/plan.md totals 45 minutes and contains two protected ten-minute build slices.
The core contract is now visible and reviewable. Those four files become the source for the second documentation group.
Missing a Group 1 File?
Ask the client to create only the missing Group 1 path. Regenerating all four files could overwrite a document you already reviewed.
If the client cannot write to the repository, copy each returned section into its matching path through your editor.
Still missing a requirement? Help me audit the four core contract documents.
Documentation group 2: Build the evidence and handoff pack
The second prompt reads the reviewed contract instead of repeating it. This keeps the prompt shorter while tying every supporting document to the same boundaries.
- Continue in the same AI conversation.
- Create the remaining documentation and repository overview by pasting this prompt:
Continue working in the same repository and conversation.
Read docs/acceptance-criteria.md, docs/decisions.md, docs/architecture.md, and docs/plan.md before writing.
If you have no workspace access, use the four Group 1 documents already returned in this conversation.
Treat those reviewed documents as the fixed source. Do not widen or reinterpret their scope.
Create these files:
- docs/ai-prompts.md: exact headings Prompt 1: Candidate CEL rules, Prompt 2: Cold Crossplane specimen, and Prompt 3: Documentation-grounded Crossplane specimen. Prompt 1 requests only candidate rules plus four isolated negative requests. Prompts 2 and 3 request one XRD plus one sample XR. Prompt 2 forbids documentation. Prompt 3 grounds every choice in the reviewed Crossplane 2.4 contract.
- docs/staleness-rubric.md: five fixed one-point checks for an XRD using apiextensions.crossplane.io/v1, LegacyCluster, claimNames, connectionSecretKeys, or an XR placing Crossplane composition controls outside spec.crossplane. Score each specimen out of 5 without changing the rubric.
- docs/readout.md: blank sections for Human corrections to Prompt 1, missing API result, four refusal exits and messages, refusal rate, Synced and Ready timings, rollout, recreation, exact scope, two stale scores, score difference, and accepted-but-stale observations.
- docs/operate.md: normal operating checks, apply order, health signals, and evidence to retain without adding new commands.
- docs/runbook.md: symptom-to-check-to-recovery guidance for missing API, absent CEL rules, stalled conditions, provider routing, and failed Deployment recreation without adding new commands.
- docs/limits.md: local-only scope, one API, one namespace, one provider package, two shared-runtime ProviderConfigs, SuccessfulCreate readiness limits, no cloud boundary, and no production claim.
- docs/teach-back.md: exactly six questions covering admission versus reconciliation, XRD purpose, CEL, ProviderConfig routing, Synced versus Ready, and accepted-but-stale APIs.
- docs/after-action-review.md: sections for expected result, observed result, timing variance, AI corrections, operational gaps, and next action.
- README.md: project purpose, contract summary, workflow, file map, acceptance summary, AI boundary, and links to all documentation files.
Use concise Markdown with concrete headings, checklists, and blank evidence placeholders.
Do not create or edit platform manifests, requests, scripts, evidence files, the XRD, the Composition, or implementation code.
If you can edit the workspace, create the files directly. Otherwise, return each file under its exact path heading.
What Does Group 2 Establish?
- The AI prompts preserve generated suggestions as evidence without granting implementation authority.
- The rubric and readout make the later comparison measurable.
- The operating, limit, and reflection files preserve the handoff after the build ends.
- The README connects the complete pack without adding a new requirement.
- Review the proposed changes before accepting them.
- Create each returned path through your editor's file sidebar if the client cannot edit the repository.
- Save the eight supporting documents plus README.md.
- Confirm that docs/ai-prompts.md contains all three exact prompt headings plus the protected-build restriction.
- Confirm that docs/staleness-rubric.md contains exactly five one-point checks and a maximum score of 5.
- Confirm that docs/readout.md contains every admission, timing, reconciliation, scope, and staleness placeholder.
- Confirm that docs/operate.md, docs/runbook.md, and docs/limits.md stay inside the reviewed local project boundary.
Expected 13-Path File Tree
- The core group contains four files: docs/acceptance-criteria.md, docs/decisions.md, docs/architecture.md, and docs/plan.md.
- The AI evidence group contains docs/ai-prompts.md, docs/staleness-rubric.md, and docs/readout.md.
- The operating group contains docs/operate.md, docs/runbook.md, and docs/limits.md.
- The reflection group contains docs/teach-back.md and docs/after-action-review.md. The repository overview is README.md.
- Confirm that docs/teach-back.md contains exactly six questions.
- Confirm that docs/after-action-review.md contains all six reflection sections.
- Confirm that README.md links the workflow, contract, documentation map, acceptance checks, and AI boundary.
- Count the completed paths. You should have 12 files under docs plus README.md.
Does the Pack Have 13 Paths?
Compare the file sidebar with the 13-path list above. Ask the client to create only a missing path or remove only an unexpected path.
If a required heading is missing, request a correction to that file without regenerating the rest of the pack.
Need a second check? Help me validate the complete documentation pack.
That is the documentation pack complete: every file exists, each group passed its own review, and the final checkpoint captures the complete file tree.
Constrain the AI draft
The first AI task has a deliberately narrow boundary. Your chosen AI client can draft candidate CEL rules plus isolated negative requests.
You remain responsible for checking every candidate against the field contract. Preserve the complete response in evidence/rules-draft.md, then record your corrections separately in docs/readout.md.
- Return to docs/ai-prompts.md.
- Confirm that Prompt 1 requests candidate rules for application, image, port, and replicas.
- Confirm that Prompt 1 requests exactly four isolated negative requests, with one request violating each field contract.
- Confirm that Prompt 1 forbids the AI client from authoring or editing the XRD, the Composition, or any repository file.
- Create an evidence folder through your editor's file sidebar.
- Create evidence/rules-draft.md inside the evidence folder.
Why keep the prompt narrow?
Prompt 1 asks for validation candidates. It never asks the assistant to author the XRD or the Composition.
This keeps tedious drafting outside the protected build block. Human review remains the gate between generated suggestions and platform code.
- Copy only Prompt 1 with all of its constraints.
- Open a new conversation in your chosen AI client.
- Paste the copied prompt into the conversation.
- Submit the prompt.
- Copy the complete response without editing it.
The evidence file is ready. Keep the copied response unchanged so it remains a reliable record of the AI draft.
- Paste the untouched response into evidence/rules-draft.md.
- Save evidence/rules-draft.md.
You should see the complete assistant response preserved under evidence.
- Review every candidate against the four-field contract.
- Review every rule against Kubernetes CEL behavior.
- Record every correction under Human corrections to Prompt 1 in docs/readout.md.
- Save docs/readout.md.
The original response remains unchanged as evidence. Only the reviewed rule fragments can move into the implementation during the next step.
Expose the missing API
A developer request can be complete while its API is still absent. This check demonstrates that gap before the XRD exists.
- Create a requests folder through the file sidebar.
You should see the empty requests folder in the repository.
- Create requests/valid.yaml inside the new folder.
- Populate requests/valid.yaml by pasting the request below:
apiVersion: platform.example.org/v1alpha1
kind: ApplicationEnvironment
metadata:
name: queue-api
namespace: platform-demo
spec:
application: queue-api
image: nginx:1.30.4-alpine
port: 80
replicas: 1
What does this request describe?
- The apiVersion and kind identify the typed platform API that the next step creates.
- The metadata places queue-api in platform-demo.
- The specification supplies one valid value for each required contract field.
- Save requests/valid.yaml.
Before you test the request, do you think the API server can recognize ApplicationEnvironment without its XRD?
- Submit the request to server-side admission without persisting it by running:
kubectl apply --dry-run=server -f requests/valid.yaml
Expected failure: the API is missing
You should see this error:
error: resource mapping not found for name: "queue-api" namespace: "platform-demo" from "requests/valid.yaml": no matches for kind "ApplicationEnvironment" in version "platform.example.org/v1alpha1"
ensure CRDs are installed first
This is the designed obstacle. The API server cannot map the request because the ApplicationEnvironment XRD has not been installed yet.
- Record the missing-resource result in docs/readout.md.
✔️ Awesome, I've got everything!
Great. Your valid request is saved, while the server-side dry run proves that its API does not exist yet.
ⓧ I'd like to double check the full code
Compare your request with the complete file below.
apiVersion: platform.example.org/v1alpha1
kind: ApplicationEnvironment
metadata:
name: queue-api
namespace: platform-demo
spec:
application: queue-api
image: nginx:1.30.4-alpine
port: 80
replicas: 1
Did You Get a Different Result?
If the terminal reports a missing file, confirm that valid.yaml sits inside the repository's requests folder.
If the request succeeds, your cluster already contains a definition for this resource kind. Remove that unexpected definition before continuing so the designed obstacle remains valid.
Still stuck? Help me diagnose my unexpected server-side dry-run result.
That failure is useful evidence: the platform contract is frozen while its typed API is still absent. Next up, you will establish the XRD and watch a rule-free first pass accept an invalid policy value.
Build the Typed API
The missing-resource dry run from the previous step proved that requests/valid.yaml has no typed API endpoint. Your control plane is ready, so the missing contract is now the blocker.
In this step, you'll create a namespaced Crossplane v2 XRD. You'll first test a type-only schema. Then you'll add four reviewed CEL rules that enforce the platform policy during admission.
In this step, get ready to:
- Establish a namespaced ApplicationEnvironment API with four required fields.
- Test the rule-free schema with an invalid image request.
- Enforce the contract with four reviewed CEL rules.
Establish the rule-free API
- In your code editor's file explorer, create a platform folder inside the repository folder.
- Inside platform, create definition.yaml.
The first pass defines the required field types without policy rules. You'll test what this structural schema actually enforces before changing it.
- In the Visual Studio Code Explorer for your repository, create a platform folder.
- Inside platform, create definition.yaml.
You should now see platform/definition.yaml in your code editor's file explorer.
- Build the opening half of platform/definition.yaml by pasting this block. Keep the file open because the YAML is completed by the next block:
apiVersion: apiextensions.crossplane.io/v2
kind: CompositeResourceDefinition
metadata:
name: applicationenvironments.platform.example.org
spec:
scope: Namespaced
group: platform.example.org
names:
kind: ApplicationEnvironment
plural: applicationenvironments
singular: applicationenvironment
shortNames:
- appenv
defaultCompositionRef:
name: application-environment
versions:
- name: v1alpha1
served: true
referenceable: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
application:
type: string
maxLength: 63
What does this definition establish?
- The API group and version identify requests as platform.example.org/v1alpha1.
- The explicit Namespaced scope keeps each XR in its request namespace.
- The defaultCompositionRef reserves application-environment as the implementation selected later.
- Complete the type-only schema by adding this block directly after maxLength: 63:
image:
type: string
maxLength: 255
port:
type: integer
replicas:
type: integer
required:
- application
- image
- port
- replicas
required:
- spec
What does the type-only schema require?
- Every request must contain spec.application plus spec.image.
- Every request must also contain spec.port plus spec.replicas.
- The schema currently checks only the declared string and integer types.
- Save platform/definition.yaml.
- Establish the rule-free XRD and inspect its status by running:
kubectl apply -f platform/definition.yaml
kubectl get xrd
What do these commands prove?
The first command registers the new platform API. The second command reads the XRD status from the cluster.
You should see applicationenvironments.platform.example.org with its established state confirmed.
XRD not established?
- Check the indentation beneath versions because every schema field must remain under v1alpha1.
- Confirm that applicationenvironments.platform.example.org matches the metadata name exactly.
Still stuck? Help me diagnose why my Crossplane XRD is not becoming Established.
- In your code editor's file explorer, create a negative folder inside the existing requests folder.
- Inside requests/negative, create invalid-image.yaml.
You should now see requests/negative/invalid-image.yaml in your code editor's file explorer beside requests/valid.yaml.
- Populate requests/negative/invalid-image.yaml with this isolated image case:
apiVersion: platform.example.org/v1alpha1
kind: ApplicationEnvironment
metadata:
name: invalid-image
namespace: platform-demo
spec:
application: invalid-image
image: nginx:latest
port: 80
replicas: 1
Why isolate one field?
This request changes only spec.image from the valid specimen. A single-field case makes the admission result attributable to one contract decision.
- Save requests/negative/invalid-image.yaml.
Before you run this check, do you think a typed string field can distinguish between two image strings?
- Submit the invalid image request through server-side dry run by running:
kubectl apply --dry-run=server -f requests/negative/invalid-image.yaml
The intended gap is now visible
The command succeeds with a zero exit because nginx:latest still satisfies the string type. The type-only schema cannot express the platform's image policy.
This is the designed failure. The next change moves that policy into admission.
Add the reviewed CEL rules
CEL rules evaluate each scalar at its schema location before the request is stored. The human-reviewed rules from evidence/rules-draft.md now turn the frozen contract into enforceable admission policy.
- Open platform/definition.yaml in your code editor.
- Replace the existing application and image field definitions with these reviewed blocks:
application:
type: string
maxLength: 63
x-kubernetes-validations:
- rule: "self.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$')"
message: "spec.application must be a lowercase DNS label"
image:
type: string
maxLength: 255
x-kubernetes-validations:
- rule: "self.matches('^.+:[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$') && !self.endsWith(':latest')"
message: "spec.image must include an explicit non-latest tag"
What do the string rules protect?
- The application rule accepts lowercase DNS-label syntax within the existing length limit.
- The image rule requires an explicit tag. It also refuses the :latest suffix.
- Each static message names the field that failed admission.
- Replace the existing port and replicas field definitions with these reviewed blocks:
port:
type: integer
x-kubernetes-validations:
- rule: "self >= 1 && self <= 65535"
message: "spec.port must be between 1 and 65535"
replicas:
type: integer
x-kubernetes-validations:
- rule: "self >= 1 && self <= 3"
message: "spec.replicas must be between 1 and 3"
What do the numeric rules protect?
- The port rule accepts values from 1 through 65535.
- The replicas rule keeps the requested count between 1 and 3.
- Both checks run before reconciliation can begin.
- Save platform/definition.yaml.
✔️ Awesome, I've got everything!
Great. Confirm that platform/definition.yaml is saved with one validation rule under each scalar field.
ⓧ I'd like to double check the full code
apiVersion: apiextensions.crossplane.io/v2
kind: CompositeResourceDefinition
metadata:
name: applicationenvironments.platform.example.org
spec:
scope: Namespaced
group: platform.example.org
names:
kind: ApplicationEnvironment
plural: applicationenvironments
singular: applicationenvironment
shortNames:
- appenv
defaultCompositionRef:
name: application-environment
versions:
- name: v1alpha1
served: true
referenceable: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
application:
type: string
maxLength: 63
x-kubernetes-validations:
- rule: "self.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$')"
message: "spec.application must be a lowercase DNS label"
image:
type: string
maxLength: 255
x-kubernetes-validations:
- rule: "self.matches('^.+:[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$') && !self.endsWith(':latest')"
message: "spec.image must include an explicit non-latest tag"
port:
type: integer
x-kubernetes-validations:
- rule: "self >= 1 && self <= 65535"
message: "spec.port must be between 1 and 65535"
replicas:
type: integer
x-kubernetes-validations:
- rule: "self >= 1 && self <= 3"
message: "spec.replicas must be between 1 and 3"
required:
- application
- image
- port
- replicas
required:
- spec
Crossplane must reload after an XRD schema change. This restart can take up to a minute while the deployment completes its rollout.
- Reapply the XRD and reload Crossplane by running:
kubectl apply -f platform/definition.yaml
kubectl rollout restart deployment/crossplane -n crossplane-system
kubectl rollout status deployment/crossplane -n crossplane-system --timeout=60s
Why restart Crossplane?
Reapplying updates the XRD stored by the API server. Restarting the Crossplane deployment reloads the expanded schema before the next admission test.
The rollout status command waits until the replacement Crossplane pod is available.
- Inspect the generated CRD by running:
kubectl get crd applicationenvironments.platform.example.org -o yaml
What should the generated CRD contain?
You should find four x-kubernetes-validations entries under the generated schema. Their messages should name spec.application, spec.image, spec.port, and spec.replicas.
🙋♀️ Validations missing from the generated CRD?
- Confirm that you saved platform/definition.yaml before reapplying it.
- Check that each x-kubernetes-validations block sits directly beneath its scalar field.
- Confirm that the Crossplane deployment rollout completed before inspecting the CRD.
Still stuck? Help me find why my generated CRD does not expose the CEL validations.
Prove admission refuses bad input
Each negative request changes one field while keeping the other three valid. This one-rule-per-file design makes every refusal measurable.
- Create requests/negative/invalid-application.yaml with this content:
apiVersion: platform.example.org/v1alpha1
kind: ApplicationEnvironment
metadata:
name: invalid-application
namespace: platform-demo
spec:
application: Queue_API
image: nginx:1.30.4-alpine
port: 80
replicas: 1
What does this case isolate?
Only spec.application violates the contract. Its uppercase characters and underscore fall outside the lowercase DNS-label rule.
- Save requests/negative/invalid-application.yaml.
- Create requests/negative/invalid-port.yaml with this content:
apiVersion: platform.example.org/v1alpha1
kind: ApplicationEnvironment
metadata:
name: invalid-port
namespace: platform-demo
spec:
application: invalid-port
image: nginx:1.30.4-alpine
port: 70000
replicas: 1
What does the port case isolate?
The 70000 value exceeds the contract's maximum port. Every other field remains valid.
- Save requests/negative/invalid-port.yaml.
- Create requests/negative/invalid-replicas.yaml with this content:
apiVersion: platform.example.org/v1alpha1
kind: ApplicationEnvironment
metadata:
name: invalid-replicas
namespace: platform-demo
spec:
application: invalid-replicas
image: nginx:1.30.4-alpine
port: 80
replicas: 0
What does the replica case isolate?
The 0 value falls below the contract's minimum replica count. The remaining fields satisfy their rules.
- Save requests/negative/invalid-replicas.yaml.
Your code editor's file explorer should now list four files under requests/negative. Each filename corresponds to one CEL rule.
A typed string field accepts both nginx:1.30.4-alpine and nginx:latest. The CEL rule must now reject the latest tag.
- Submit the intentionally invalid image request through server-side dry run:
kubectl apply --dry-run=server -f requests/negative/invalid-image.yaml
Expected refusal: this test passes
Expected: the command fails with a non-zero exit. The API server names spec.image and reports that the tag must be explicit and not latest.
The ApplicationEnvironment "invalid-image" is invalid: spec.image: Invalid value: "nginx:latest": spec.image must include an explicit non-latest tag
That refusal is a passing result because admission enforced the reviewed image policy. Your cluster and negative test file are working as intended.
- Keep requests/negative/invalid-image.yaml unchanged.
- Keep the image rule in platform/definition.yaml unchanged.
- Record the non-zero exit and field-named message in docs/readout.md.
- Use requests/valid.yaml for the clean acceptance path.
- Keep .cursor/rules/negative-admission-refusals.mdc in place so AI-assisted edits preserve this boundary.
- Run each negative admission check separately with these commands:
kubectl apply --dry-run=server -f requests/negative/invalid-application.yaml
kubectl apply --dry-run=server -f requests/negative/invalid-image.yaml
kubectl apply --dry-run=server -f requests/negative/invalid-port.yaml
kubectl apply --dry-run=server -f requests/negative/invalid-replicas.yaml
Four refusals complete acceptance
All four commands must exit non-zero. Each message identifies the field whose policy rejected the request.
- spec.application must be a lowercase DNS label.
- spec.image must include an explicit non-latest tag.
- spec.port must be between 1 and 65535.
- spec.replicas must be between 1 and 3.
That is the admission boundary proven: four isolated bad values produce four measurable refusals.
- Record each non-zero exit and its field-named message under the four-refusal section in docs/readout.md.
- Test the no-refusal path without persisting the request by running:
kubectl apply --dry-run=server -f requests/valid.yaml
Valid input follows the accept path
You should see a successful server-side dry run with a zero exit. This request satisfies all four field rules.
Use this valid specimen whenever you need an acceptance path. Keep the files under requests/negative for the four required refusals.
Your typed API is established. It now refuses invalid contract values before they reach Crossplane's reconciliation path. Next, you'll connect accepted requests to the function pipeline that creates the Deployment and Service.
Reconcile an Environment
Your API server now refuses requests that break the platform contract. An accepted request still exists only as desired data.
This step adds Crossplane reconciliation. A function pipeline turns each accepted request into a Deployment plus a Service. The provider-kubernetes package continuously keeps those resources aligned with the request.
In this step, get ready to:
- Write a pipeline Composition that produces two provider Objects.
- Measure how quickly the valid request reaches both conditions.
- Delete the Deployment to prove that reconciliation recreates it.
Write the Composition
A Composition defines how an accepted ApplicationEnvironment becomes concrete resources. This Composition uses Function Patch and Transform to copy request values into two provider-kubernetes Objects.
Why Keep the Provider Boundary?
Crossplane can compose native Kubernetes resources directly. This project keeps provider-kubernetes in the path because the platform contract sits in front of an inherited provider boundary.
The two ProviderConfigs make workload routing visible. Both still use the same provider runtime plus the same local cluster.
- In your editor's file sidebar, create platform/composition.yaml inside the existing platform folder.
- Add the Composition header plus the Deployment Object wrapper by copying this first section:
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
name: application-environment
spec:
compositeTypeRef:
apiVersion: platform.example.org/v1alpha1
kind: ApplicationEnvironment
mode: Pipeline
pipeline:
- step: patch-and-transform
functionRef:
name: function-patch-and-transform
input:
apiVersion: pt.fn.crossplane.io/v1beta1
kind: Resources
resources:
- name: deployment
base:
apiVersion: kubernetes.m.crossplane.io/v1alpha1
kind: Object
spec:
readiness:
policy: SuccessfulCreate
providerConfigRef:
kind: ProviderConfig
name: workloads
What Does This Section Define?
- The Composition targets platform.example.org/v1alpha1 requests with the ApplicationEnvironment kind.
- Pipeline mode sends the desired resources through function-patch-and-transform.
- The first Object uses the existing workloads ProviderConfig.
- SuccessfulCreate reports this Object as ready after its external resource is created.
- Continue directly below name: workloads by adding the embedded Deployment manifest:
forProvider:
manifest:
apiVersion: apps/v1
kind: Deployment
metadata:
name: placeholder
namespace: placeholder
labels:
app.kubernetes.io/name: placeholder
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: placeholder
template:
metadata:
labels:
app.kubernetes.io/name: placeholder
spec:
containers:
- name: application
image: nginx:1.30.4-alpine
ports:
- name: http
containerPort: 80
How Does the Deployment Base Work?
The base supplies a complete Deployment shape with safe placeholder values. Later patches replace those values with fields from the accepted request.
The selector matches the pod label. The container exposes port 80 through the pinned NGINX image.
- Save platform/composition.yaml.
- Create the first valid Composition stage by running this command:
kubectl apply -f platform/composition.yaml
What Does This Command Prove?
The API server accepts the Composition structure. Crossplane can also resolve its target XRD plus its referenced function.
You should see confirmation that the application-environment Composition was created.
Composition Rejected?
- Check that forProvider aligns beneath the Deployment Object's spec field.
- Confirm that every indentation level uses spaces.
Still stuck? Help me diagnose why my Crossplane Composition YAML is being rejected.
The Deployment base still contains placeholders. Patches connect each placeholder to the matching field in the composite request.
- Append the namespace plus application-name patches directly below the Deployment's containerPort: 80 line:
patches:
- type: FromCompositeFieldPath
fromFieldPath: metadata.namespace
toFieldPath: spec.forProvider.manifest.metadata.namespace
policy:
fromFieldPath: Required
- type: FromCompositeFieldPath
fromFieldPath: spec.application
toFieldPath: spec.forProvider.manifest.metadata.name
policy:
fromFieldPath: Required
- type: FromCompositeFieldPath
fromFieldPath: spec.application
toFieldPath: spec.forProvider.manifest.metadata.labels["app.kubernetes.io/name"]
policy:
fromFieldPath: Required
- type: FromCompositeFieldPath
fromFieldPath: spec.application
toFieldPath: spec.forProvider.manifest.spec.selector.matchLabels["app.kubernetes.io/name"]
policy:
fromFieldPath: Required
What Do These Patches Connect?
- The XR namespace becomes the Deployment namespace.
- The application value becomes the Deployment name.
- The same application value supplies the resource label plus selector.
- Required patch policies stop rendering when a source field is missing.
- Continue the same patches list with the pod-label plus container-setting patches:
- type: FromCompositeFieldPath
fromFieldPath: spec.application
toFieldPath: spec.forProvider.manifest.spec.template.metadata.labels["app.kubernetes.io/name"]
policy:
fromFieldPath: Required
- type: FromCompositeFieldPath
fromFieldPath: spec.image
toFieldPath: spec.forProvider.manifest.spec.template.spec.containers[0].image
policy:
fromFieldPath: Required
- type: FromCompositeFieldPath
fromFieldPath: spec.port
toFieldPath: spec.forProvider.manifest.spec.template.spec.containers[0].ports[0].containerPort
policy:
fromFieldPath: Required
- type: FromCompositeFieldPath
fromFieldPath: spec.replicas
toFieldPath: spec.forProvider.manifest.spec.replicas
policy:
fromFieldPath: Required
How Does the Request Shape the Workload?
These patches carry the validated application label into the pod template. They also set the image plus container port plus replica count.
Admission has already checked each source value. The pipeline now uses those accepted values to render the workload.
- Save platform/composition.yaml.
- Update the live Composition with the Deployment patches by running:
kubectl apply -f platform/composition.yaml
What Changed in the Live Composition?
The live Composition now renders a Deployment from request fields. Applying the file updates the existing definition without creating an application request.
You should see confirmation that the Composition was updated.
The second Object gives the application a stable internal network endpoint. It routes through the existing networking ProviderConfig.
- Append the Service Object base after the final Deployment patch:
- name: service
base:
apiVersion: kubernetes.m.crossplane.io/v1alpha1
kind: Object
spec:
readiness:
policy: SuccessfulCreate
providerConfigRef:
kind: ProviderConfig
name: networking
forProvider:
manifest:
apiVersion: v1
kind: Service
metadata:
name: placeholder
namespace: placeholder
labels:
app.kubernetes.io/name: placeholder
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: placeholder
ports:
- name: http
protocol: TCP
port: 80
targetPort: 80
How Does the Service Base Work?
The Service selects pods through the shared application label. Its ClusterIP exposes TCP port 80 inside the local cluster.
The networking ProviderConfig makes the resource route explicit. It still shares the same provider runtime used by the workloads ProviderConfig.
- Save platform/composition.yaml.
- Add the Service base to the live Composition by running:
kubectl apply -f platform/composition.yaml
What Does This Update Establish?
The Composition now declares two desired provider Objects. The Deployment and Service remain independent resources with separate ProviderConfig routes.
You should see another update confirmation for the Composition.
- Append the Service namespace plus naming patches directly below targetPort: 80:
patches:
- type: FromCompositeFieldPath
fromFieldPath: metadata.namespace
toFieldPath: spec.forProvider.manifest.metadata.namespace
policy:
fromFieldPath: Required
- type: FromCompositeFieldPath
fromFieldPath: spec.application
toFieldPath: spec.forProvider.manifest.metadata.name
policy:
fromFieldPath: Required
- type: FromCompositeFieldPath
fromFieldPath: spec.application
toFieldPath: spec.forProvider.manifest.metadata.labels["app.kubernetes.io/name"]
policy:
fromFieldPath: Required
What Do the First Service Patches Set?
The Service lands in the XR namespace. Its name plus label come from the validated application field.
- Finish the Service patches list with its selector plus port mappings:
- type: FromCompositeFieldPath
fromFieldPath: spec.application
toFieldPath: spec.forProvider.manifest.spec.selector["app.kubernetes.io/name"]
policy:
fromFieldPath: Required
- type: FromCompositeFieldPath
fromFieldPath: spec.port
toFieldPath: spec.forProvider.manifest.spec.ports[0].port
policy:
fromFieldPath: Required
- type: FromCompositeFieldPath
fromFieldPath: spec.port
toFieldPath: spec.forProvider.manifest.spec.ports[0].targetPort
policy:
fromFieldPath: Required
How Does Traffic Reach the Pods?
The Service selector matches the Deployment's pod label. The request port becomes both the Service port plus its target port.
One application value now links the Deployment plus Service. One port value links the container plus network endpoint.
- Save the completed platform/composition.yaml file.
- Install the final Composition by running:
kubectl apply -f platform/composition.yaml
What Is Ready Now?
The platform implementation is now installed. Any accepted ApplicationEnvironment can enter this pipeline through the XRD's default Composition reference.
You should see confirmation that application-environment was updated.
Final Composition Update Failing?
- Check that the Service patches key aligns with its base key.
- Confirm that the Service entry begins at the same indentation level as the Deployment entry.
- Verify that every quoted label key retains its square brackets plus double quotes.
Need another pair of eyes? Help me compare my Composition indentation with the intended two-resource pipeline.
✔️ Awesome, I've got everything!
Great. Your saved Composition contains one Deployment Object plus one Service Object.
ⓧ I'd like to double check the full code
Compare your complete platform/composition.yaml file with this reference:
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
name: application-environment
spec:
compositeTypeRef:
apiVersion: platform.example.org/v1alpha1
kind: ApplicationEnvironment
mode: Pipeline
pipeline:
- step: patch-and-transform
functionRef:
name: function-patch-and-transform
input:
apiVersion: pt.fn.crossplane.io/v1beta1
kind: Resources
resources:
- name: deployment
base:
apiVersion: kubernetes.m.crossplane.io/v1alpha1
kind: Object
spec:
readiness:
policy: SuccessfulCreate
providerConfigRef:
kind: ProviderConfig
name: workloads
forProvider:
manifest:
apiVersion: apps/v1
kind: Deployment
metadata:
name: placeholder
namespace: placeholder
labels:
app.kubernetes.io/name: placeholder
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: placeholder
template:
metadata:
labels:
app.kubernetes.io/name: placeholder
spec:
containers:
- name: application
image: nginx:1.30.4-alpine
ports:
- name: http
containerPort: 80
patches:
- type: FromCompositeFieldPath
fromFieldPath: metadata.namespace
toFieldPath: spec.forProvider.manifest.metadata.namespace
policy:
fromFieldPath: Required
- type: FromCompositeFieldPath
fromFieldPath: spec.application
toFieldPath: spec.forProvider.manifest.metadata.name
policy:
fromFieldPath: Required
- type: FromCompositeFieldPath
fromFieldPath: spec.application
toFieldPath: spec.forProvider.manifest.metadata.labels["app.kubernetes.io/name"]
policy:
fromFieldPath: Required
- type: FromCompositeFieldPath
fromFieldPath: spec.application
toFieldPath: spec.forProvider.manifest.spec.selector.matchLabels["app.kubernetes.io/name"]
policy:
fromFieldPath: Required
- type: FromCompositeFieldPath
fromFieldPath: spec.application
toFieldPath: spec.forProvider.manifest.spec.template.metadata.labels["app.kubernetes.io/name"]
policy:
fromFieldPath: Required
- type: FromCompositeFieldPath
fromFieldPath: spec.image
toFieldPath: spec.forProvider.manifest.spec.template.spec.containers[0].image
policy:
fromFieldPath: Required
- type: FromCompositeFieldPath
fromFieldPath: spec.port
toFieldPath: spec.forProvider.manifest.spec.template.spec.containers[0].ports[0].containerPort
policy:
fromFieldPath: Required
- type: FromCompositeFieldPath
fromFieldPath: spec.replicas
toFieldPath: spec.forProvider.manifest.spec.replicas
policy:
fromFieldPath: Required
- name: service
base:
apiVersion: kubernetes.m.crossplane.io/v1alpha1
kind: Object
spec:
readiness:
policy: SuccessfulCreate
providerConfigRef:
kind: ProviderConfig
name: networking
forProvider:
manifest:
apiVersion: v1
kind: Service
metadata:
name: placeholder
namespace: placeholder
labels:
app.kubernetes.io/name: placeholder
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: placeholder
ports:
- name: http
protocol: TCP
port: 80
targetPort: 80
patches:
- type: FromCompositeFieldPath
fromFieldPath: metadata.namespace
toFieldPath: spec.forProvider.manifest.metadata.namespace
policy:
fromFieldPath: Required
- type: FromCompositeFieldPath
fromFieldPath: spec.application
toFieldPath: spec.forProvider.manifest.metadata.name
policy:
fromFieldPath: Required
- type: FromCompositeFieldPath
fromFieldPath: spec.application
toFieldPath: spec.forProvider.manifest.metadata.labels["app.kubernetes.io/name"]
policy:
fromFieldPath: Required
- type: FromCompositeFieldPath
fromFieldPath: spec.application
toFieldPath: spec.forProvider.manifest.spec.selector["app.kubernetes.io/name"]
policy:
fromFieldPath: Required
- type: FromCompositeFieldPath
fromFieldPath: spec.port
toFieldPath: spec.forProvider.manifest.spec.ports[0].port
policy:
fromFieldPath: Required
- type: FromCompositeFieldPath
fromFieldPath: spec.port
toFieldPath: spec.forProvider.manifest.spec.ports[0].targetPort
policy:
fromFieldPath: Required
Apply the Composition First
The always-on project rule in .cursor/rules/composition-before-request.mdc keeps Cursor focused on the Composition before requests. Your terminal apply order still controls which version Crossplane uses for reconciliation.
- Finish platform/composition.yaml with the Deployment Object, Service Object, and every patch.
- Apply the completed platform/composition.yaml file to the cluster.
- Apply requests/valid.yaml only after the Composition update succeeds.
Keep the negative request files unchanged. They test admission failures, while this order prevents an incomplete Composition from rendering placeholder resources.
Apply the valid request
The valid request already contains the application name plus image plus port plus replica count. Applying it starts the complete admission-to-reconciliation path.
- Confirm the live Composition target plus pipeline structure by running:
kubectl get composition application-environment -o yaml
What Should the Composition Show?
The output should target platform.example.org/v1alpha1 plus ApplicationEnvironment. It should also show Pipeline mode with the function-patch-and-transform function.
- Start a stopwatch.
- Submit the valid request by running:
kubectl apply -f requests/valid.yaml
What Happens After Admission?
Kubernetes accepts the request because all four values satisfy the XRD schema. Crossplane then selects application-environment through the default Composition reference.
The function renders two Objects. Each provider route then creates its target resource.
Before you run the readiness checks, do you expect both XR conditions to become true inside the measured one-minute window?
- Wait for successful synchronization plus readiness by running these checks:
kubectl wait --for=condition=Synced applicationenvironment/queue-api -n platform-demo --timeout=60s
kubectl wait --for=condition=Ready applicationenvironment/queue-api -n platform-demo --timeout=60s
What Do the Two Conditions Mean?
- Synced confirms that Crossplane completed a successful reconciliation.
- Ready confirms that the function pipeline reports its composed resources as ready.
- Both successful commands keep the request inside the measured acceptance window.
- Stop the stopwatch after the second wait succeeds.
- Record the total elapsed time as your measured seconds.
- Inspect both provider Objects by running:
kubectl get objects.kubernetes.m.crossplane.io -n platform-demo -o yaml
What Should the Provider Objects Show?
You should find two composed Objects. The Deployment Object references workloads. The Service Object references networking.
Both Objects use SuccessfulCreate readiness. Their embedded manifests target the platform-demo namespace.
- Confirm the rendered Deployment plus Service by running:
kubectl get deployment/queue-api service/queue-api -n platform-demo
What Should the Rendered Resources Show?
You should see both deployment.apps/queue-api plus service/queue-api in platform-demo.
The Deployment runs one replica. The Service receives an internal ClusterIP.
🙋♀️ XR Conditions Not Becoming True?
- Confirm that both ProviderConfigs still exist in platform-demo.
- Check that the Composition uses workloads for the Deployment Object.
- Check that the Composition uses networking for the Service Object.
Need help tracing the conditions? Help me diagnose why my ApplicationEnvironment is not reaching Synced and Ready.
🙋♀️ Seeing services "queue-api" not found?
- Treat this as an apply-order stall. Leave the negative request files unchanged.
- Reapply the completed Composition and inspect the reconciled resources by running:
kubectl apply -f platform/composition.yaml
kubectl get objects.kubernetes.m.crossplane.io -n platform-demo
kubectl get deployment/queue-api service/queue-api -n platform-demo
- Confirm that the Service Object uses queue-api in platform-demo, not placeholder.
- Confirm that the final command lists both deployment.apps/queue-api and service/queue-api.
- Still seeing placeholder? Help me trace the stale Service Object and apply order.
Prove continuous reconciliation
SuccessfulCreate readiness proves that each provider Object created its target. The Deployment rollout confirms that the sample workload becomes available.
Reconciliation is continuous control. Deleting the managed Deployment gives you a visible test of that behavior.
- Confirm the NGINX Deployment completes its rollout by running:
kubectl rollout status deployment/queue-api -n platform-demo --timeout=60s
What Does the Rollout Check Add?
The Object readiness policy confirms resource creation. The rollout check confirms that the Deployment's pod became available.
You should see confirmation that the Deployment rollout completed.
Before you delete the Deployment, do you expect it to stay absent or return automatically?
This Deletion Is Safe
You are deleting only the generated Deployment inside the local cluster. The provider Object remains present with the desired manifest.
That remaining desired state gives provider-kubernetes everything it needs to recreate the Deployment.
- Delete the generated Deployment by running:
kubectl delete deployment queue-api -n platform-demo
What Did This Command Remove?
The command removes the live Deployment. It leaves the ApplicationEnvironment plus composed Object intact.
- Wait about ten seconds for the next provider reconciliation.
- Check for the recreated resources by running:
kubectl get deployment/queue-api service/queue-api -n platform-demo
What Proves Reconciliation?
You should see deployment.apps/queue-api again. The Service should remain present throughout the test.
The returning Deployment proves that provider-kubernetes continuously compares desired state with live state.
- Run the final rollout verification against the recreated Deployment:
kubectl rollout status deployment/queue-api -n platform-demo --timeout=60s
What Should the Final Check Confirm?
You should see confirmation that the recreated Deployment completed its rollout. The application container is running nginx:1.30.4-alpine on container port 80.
That completes the full loop from typed request to admission to function rendering to continuous reconciliation.
🙋♀️ Deployment Not Returning?
- Inspect the Deployment provider Object for a synchronization error.
- Confirm that its providerConfigRef still names workloads.
- Confirm that the provider ServiceAccount still has permission to create Deployments in platform-demo.
Still waiting? Help me find why provider-kubernetes did not recreate queue-api after deletion.
That is the control loop working. Your typed request now produces a running Deployment plus Service, then repairs the Deployment when live state drifts.
Secret mission
Measure Contract Enforcement and API Staleness
Your platform rejects requests that violate its field contract. This mission measures all four admission rules. It also verifies the live Crossplane scope and scores two assistant-generated definitions for stale API patterns.
Clean Up Your Resources
Clean Up Your Resources
Your platform can remain available for inspection, pause locally, or be removed entirely. This local cluster creates no cloud charges.
Cost warning
Local Kubernetes creates no cloud charges. Crossplane packages also create no cloud charges.
Docker Desktop requires a paid subscription for government entities. The same requirement applies to some larger organizations.
Resources you used:
- The local typed-platform-api workspace folder on your Desktop.
- The local kind cluster typed-platform-api.
- Crossplane core in the cluster. The provider-kubernetes package sits beside the Function Patch and Transform package.
- The platform-demo namespace. Its namespace-scoped RBAC grants the target permissions. Its workloads ProviderConfig routes the Deployment. Its networking ProviderConfig routes the Service.
- The namespaced ApplicationEnvironment XRD. Its Composition pipeline manages the valid XR.
- Two provider Objects. One represents the Deployment. The other represents the Service.
The Keep and Pause options preserve your completed evidence files. The Delete option removes them with the workspace after a clear warning.
Keep everything running
No action is needed. Choose this option if you want to inspect the reconciled environment or repeat the measurements.
- Leave the typed-platform-api cluster running for later inspection.
- Keep the completed evidence files for comparison.
- Keep the completed documentation as your measured project record.
Pause - I'll come back to this later
Shut down the running control-plane container to free local memory while preserving the cluster for later. Your completed files stay on disk.
- Stop the typed-platform-api-control-plane container by running this command in your terminal or PowerShell:
docker stop typed-platform-api-control-plane
What does this command do?
This stops the control-plane container. It preserves the local cluster so you can start the same container later.
When you return, the same container brings the local control plane back.
- Restart the control-plane container by running this command in your terminal or PowerShell:
docker start typed-platform-api-control-plane
What does this command do?
This starts the existing control-plane container. Your cluster resources become available for inspection again.
Delete - I don't want to use this again
Deleting the cluster removes only the local typed-platform-api control plane. Deleting the workspace is permanent, so copy any evidence you want to keep before running the folder command.
- Delete the named cluster by running this command in your terminal or PowerShell:
kind delete cluster --name typed-platform-api
What does this remove?
kind deletes the typed-platform-api cluster. This removes Crossplane core plus every package and resource stored inside that cluster.
Your workspace and evidence files remain on your Desktop until you remove the folder in the next action.
- Delete the typed-platform-api workspace and confirm that it is gone by using the tab for your operating system:
macOS
- Remove the workspace from your Desktop by running these commands:
cd ~/Desktop
rm -rf typed-platform-api
ls
You should no longer see typed-platform-api in the Desktop listing.
Windows
- Remove the workspace from your Desktop by running these commands in PowerShell:
Set-Location -Path "$env:USERPROFILE\Desktop"
Remove-Item -LiteralPath ".\typed-platform-api" -Recurse -Force
Get-ChildItem
You should no longer see typed-platform-api in the Desktop listing.
Linux
- Remove the workspace from your Desktop by running these commands:
cd ~/Desktop
rm -rf typed-platform-api
ls
You should no longer see typed-platform-api in the Desktop listing.
What does this remove?
This removes the repository, project manifests, and evidence files stored inside the workspace. It does not remove the kind cluster because the earlier command handles that separately.
Nice Work!
Nice Work!
You did it! Your typed Crossplane API now rejects invalid ApplicationEnvironment requests during Kubernetes admission.
A valid request also reconciles into a running Deployment plus a Service on your local control plane.
You've learned how to:
- Defined a namespaced application contract with four CEL admission rules. Each refusal identifies the field that broke the contract.
- Reconciled a valid request through a Function Patch and Transform pipeline. Two provider-kubernetes ProviderConfigs route the resulting Deployment plus Service.
- Produced measured evidence for Synced=True timing. You also recorded Ready=True timing. Your readout captures the 4 / 4 = 100% refusal rate. It also preserves Deployment recreation plus the exact Namespaced scope result.
- Completed the optional Secret Mission by scoring cold plus documentation-grounded assistant specimens against a sealed five-item rubric. The numeric difference measures API-pattern staleness separately from admission validity.
Ready to quiz yourself?