Skip to main content

Crossplane Ep 14: Custom Composition Webhooks

Rachmat Hidayat
Author
Rachmat Hidayat
Learn & sharing insights on TypeScript, Go, Kubernetes, DevOps, DevSecOps, SRE, Platform Engineering, AI/ML Engineering, and MLOps.
crossplane - This article is part of a series.
Part 14: This Article
If an Application Developer submits a PostgreSQLInstance Claim asking for storageGB: 99999, the Kubernetes API will accept it (because it is a valid integer). Crossplane will then happily attempt to provision a 100TB database in AWS, bankrupting your company. We must build a mathematical firewall to stop this before it reaches the Control Plane. We need a Validating Webhook.

1. How Kubernetes Webhooks Work
#

A Kubernetes Validating Webhook is a standard HTTP server that sits in front of etcd.

When you run kubectl apply -f claim.yaml:

  1. The Kubernetes API server parses the YAML.
  2. It sends an HTTP POST request (containing the YAML data) to your Webhook server.
  3. Your server runs arbitrary Go logic (e.g., if storageGB > 1000 { return false }).
  4. If your server returns HTTP 200 (Allow), the API server saves the Claim to the database, and Crossplane begins provisioning.
  5. If your server returns HTTP 403 (Deny), the API server instantly drops the request, and the developer sees an error in their terminal.

This guarantees that Crossplane never sees invalid data.


2. Writing the Webhook in Go
#

Because a Webhook is just an HTTP server that accepts a specific JSON schema (an AdmissionReview object), we can write it using the standard Go net/http library.

Create a file named webhook.go:

package main

import (
	"encoding/json"
	"fmt"
	"io/ioutil"
	"net/http"

	admissionv1 "k8s.io/api/admission/v1"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

// Define the structure of the Developer's Claim
type PostgreSQLClaim struct {
	Spec struct {
		Parameters struct {
			StorageGB int    `json:"storageGB"`
			Env       string `json:"environment"`
		} `json:"parameters"`
	} `json:"spec"`
}

func handleValidate(w http.ResponseWriter, r *http.Request) {
	// 1. Read the HTTP Request Body from the Kubernetes API Server
	body, _ := ioutil.ReadAll(r.Body)

	// 2. Unmarshal the AdmissionReview object
	var review admissionv1.AdmissionReview
	json.Unmarshal(body, &review)

	// 3. Extract the raw Crossplane Claim from the request
	var claim PostgreSQLClaim
	json.Unmarshal(review.Request.Object.Raw, &claim)

	// 4. THE FIREWALL LOGIC
	allowed := true
	message := "Claim is valid."

	// Rule A: Max Storage Size
	if claim.Spec.Parameters.StorageGB > 500 {
		allowed = false
		message = "Security Violation: Storage cannot exceed 500GB."
	}

	// Rule B: Enforce increments of 10
	if claim.Spec.Parameters.StorageGB%10 != 0 {
		allowed = false
		message = "Compliance Violation: Storage must be an increment of 10."
	}

	// 5. Build the Response
	responseReview := admissionv1.AdmissionReview{
		TypeMeta: review.TypeMeta,
		Response: &admissionv1.AdmissionResponse{
			UID:     review.Request.UID,
			Allowed: allowed,
			Result: &metav1.Status{
				Message: message,
			},
		},
	}

	// 6. Send the HTTP Response back to the API Server
	respBytes, _ := json.Marshal(responseReview)
	w.Header().Set("Content-Type", "application/json")
	w.Write(respBytes)
}

func main() {
	http.HandleFunc("/validate", handleValidate)
	
	// Webhooks MUST run over HTTPS! You must mount TLS certificates.
	fmt.Println("Starting Webhook Server on :8443")
	http.ListenAndServeTLS(":8443", "/tls/tls.crt", "/tls/tls.key", nil)
}

3. Deploying the Webhook
#

Step 1: Containerize and Run
#

You must compile this Go code, build a Docker image, and deploy it as a standard Kubernetes Deployment and Service inside your cluster. (Ensure the Service is named validation-webhook on port 443).

Step 2: Configure the API Server
#

Once your Go HTTP server is running, you must instruct the Kubernetes API Server to forward all Crossplane Claims to it.

You do this using a ValidatingWebhookConfiguration.

Create webhook-config.yaml:

apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingWebhookConfiguration
metadata:
  name: crossplane-claim-validator
webhooks:
  - name: validate.acmecorp.com
    rules:
      - apiGroups: ["database.acmecorp.com"]
        apiVersions: ["v1alpha1"]
        # Intercept operations on our custom Crossplane Claim!
        operations: ["CREATE", "UPDATE"]
        resources: ["postgresqlinstances"]
    clientConfig:
      service:
        name: validation-webhook
        namespace: platform-system
        path: "/validate"
      # You MUST provide the CA Bundle that signed the TLS cert of your Go server!
      caBundle: "LS0tLS1CRUdJTiBDRV..."
    admissionReviewVersions: ["v1"]
    sideEffects: None

Apply this to the cluster:

kubectl apply -f webhook-config.yaml

4. The Developer Experience
#

Now, let’s pretend to be the Application Developer. We submit a bad Claim:

# bad-claim.yaml
apiVersion: database.acmecorp.com/v1alpha1
kind: PostgreSQLInstance
metadata:
  name: rogue-db
spec:
  parameters:
    storageGB: 5000
    environment: prod

When they run kubectl apply:

kubectl apply -f bad-claim.yaml

Expected Terminal Output:

Error from server: error when creating "bad-claim.yaml": admission webhook "validate.acmecorp.com" denied the request: Security Violation: Storage cannot exceed 500GB.

The YAML is instantly rejected. etcd never stores the object. Crossplane never sees the object. AWS is perfectly safe.

You have successfully built an impenetrable governance layer.


Troubleshooting & Common Errors
#

  1. Internal error occurred: failed calling webhook: Post "https://...": x509: certificate signed by unknown authority

    • Root Cause: Kubernetes requires Webhooks to use strict TLS encryption. The API server rejected your Go server’s SSL certificate because it doesn’t trust the CA.
    • Solution: Ensure the caBundle in your ValidatingWebhookConfiguration exactly matches the CA that generated the tls.crt mounted to your Go server. (Many teams use cert-manager to automate this).
  2. The Webhook ignores the Claim entirely

    • Root Cause: The apiGroups or resources arrays in the webhook config do not match the XRD definitions.
    • Solution: Double check your plurals. If your XRD is postgresqlinstances, ensure the webhook config says postgresqlinstances.

Conclusion & Next Steps
#

Webhooks are the ultimate expression of Platform Engineering security. By combining the strict OpenAPI schemas of Crossplane XRDs with the dynamic, programmable validation of Go Webhooks, you can safely hand infrastructure provisioning keys to every developer in the company.

However, writing YAML files—even simple Claims—is still not the ideal developer experience. Developers want a beautiful Web UI with dropdown menus and buttons.

In the Final Episode (Ep 15): Building an IDP with Backstage, we will complete the Platform Engineering journey. We will integrate Crossplane with Spotify’s Backstage, allowing developers to click a button in their browser to instantly provision AWS infrastructure.

crossplane - This article is part of a series.
Part 14: This Article