CRD Design and Go API Types

Table of Contents


Introduction

CRD design is where most operator complexity starts. Getting the API types right matters more than getting the reconcile logic right — type changes are hard to migrate, and a poorly modelled spec leads to a controller full of edge cases.

This article covers:

  • What a CRD actually is in Go

  • Designing the AppStack spec and status

  • Using kubebuilder markers for validation and generation

  • How make manifests turns Go structs into OpenAPI-validated CRD YAML


CRD Anatomy

A CRD is a Kubernetes API extension. When you apply a CRD to a cluster, the API server starts accepting resources of that type. A CRD defines:

  • API group/version/kind: apps.htunn.io/v1alpha1/AppStack

  • Spec schema: the fields users define (desired state)

  • Status schema: the fields the controller writes (observed state)

  • Validation: field types, required/optional, enum values, numeric ranges

  • Subresources: whether status and scale are separate subresources

In Go, a CRD is represented by three structs:

The metav1.TypeMeta and metav1.ObjectMeta are embedded from k8s.io/apimachinery/pkg/apis/meta/v1. You don't define them — they're inherited.


Designing the AppStack API

The AppStack resource manages three Kubernetes resources for a given application:

  • Deployment — runs the container

  • Service — exposes it on a port within the cluster

  • HorizontalPodAutoscaler — scales replicas based on CPU (optional)

What does the user need to declare? Only what's variable across applications:

Field
What it controls

spec.image

Container image (repository + tag)

spec.port

Port the container listens on

spec.replicas

Desired replica count (overridden by HPA if autoscaling enabled)

spec.env

Environment variables

spec.autoscaling.enabled

Whether to create an HPA

spec.autoscaling.minReplicas

HPA minimum

spec.autoscaling.maxReplicas

HPA maximum

spec.autoscaling.cpuTargetPercent

HPA CPU utilization target

Everything else — label selectors, pod templates, readiness probes — the operator sets with sensible defaults.


Writing the Go Types

Open api/v1alpha1/appstack_types.go. The scaffold already has stub structs. Replace them with the full type definition:


kubebuilder Validation Markers

kubebuilder reads Go struct comments that start with // +kubebuilder: and generates corresponding OpenAPI v3 schema in the CRD YAML. This means validation runs at the API server level — invalid resources are rejected before your controller ever sees them.

Common Validation Markers

Object-Level Markers

Applied to the top-level struct:

The subresource:status marker is important. It means:

  • .spec updates go through r.Update()

  • .status updates go through r.Status().Update()

  • These are separate API calls, and separate RBAC permissions

This prevents users from accidentally overwriting status when updating spec.

The printcolumn markers control what kubectl get appstack shows:

Result:


Status Design with Conditions

metav1.Condition is the standard Kubernetes API type for expressing resource status. Using it correctly makes your operator consistent with built-in controllers and visible to standard tooling.

For AppStack, three conditions cover the lifecycle:

Condition

True means

False means

Available

≥1 replica is ready

Deployment exists but has 0 ready replicas

Progressing

Reconcile is actively working

Reconcile is stable, no changes in progress

Degraded

An error occurred

No errors

A healthy AppStack looks like:

A deploying AppStack:

A failed AppStack:

Reason Codes

Reason must be CamelCase with no spaces. These are constants:


Generating CRD YAML

After writing the Go types:

The generated CRD YAML looks like:

Check the generated file at config/crd/bases/apps.htunn.io_appstacks.yaml to verify your markers produced the intended schema.

Install the updated CRD:


Writing a Sample Manifest

kubebuilder generates config/samples/apps_v1alpha1_appstack.yaml. Update it with a realistic sample:

Apply it:

At this point, the spec is accepted by the API server (validation passes), but no resources are created yet — that's the controller's job, covered in the next article.


Common Mistakes

Forgetting +kubebuilder:subresource:status Without this, calling r.Status().Update() fails with a "not found" error on the status subresource. Always add this marker to your root type.

Using error in status fields Status structs must be JSON-serializable and deepcopy-able. Use string for error messages, not error. The metav1.Condition.Message field is the right place for error descriptions.

Making required fields optional in the spec If image is truly required to do anything, mark it required (no // +optional). Accepting a resource with an empty image and failing in the controller is harder to debug than a validation rejection at apply time.

Not using pointer types for optional numeric fields Replicas *int32 vs Replicas int32 — the pointer form allows nil (field omitted). Non-pointer int32 defaults to 0 when not provided, which is a valid but often undesired value. Use pointer types for optional numeric fields.

Editing zz_generated.deepcopy.go This file is regenerated on every make generate. Never edit it manually.


Next: The Controller and Reconcile Loop →

Last updated