| name | k8s-crd-lifecycle |
| description | CRD type definitions, kubebuilder markers, CEL validation, code generation, and versioning for Kubernetes control planes |
CRD Lifecycle Management
Design, validate, and evolve Custom Resource Definitions for Kubernetes control planes. Covers type definitions, validation with CEL (not webhooks), code generation, and version management.
Type Definition Patterns
Spec/Status Separation
Every CRD follows the Spec/Status convention:
type MyResource struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec MyResourceSpec `json:"spec"`
Status MyResourceStatus `json:"status,omitempty"`
}
type MyResourceSpec struct {
BackendRef BackendRef `json:"backendRef"`
Timeout *metav1.Duration `json:"timeout,omitempty"`
}
type MyResourceStatus struct {
Conditions []metav1.Condition `json:"conditions,omitempty"`
}
Field Convention Rules
| Rule | Example | Notes |
|---|
| Optional fields use pointers | *string, *int32, *MyType | nil means "not configured" |
| JSON tags are lowercase camelCase | json:"backendRef,omitempty" | Always include omitempty for optional |
| Doc comment on every exported type/field | // Timeout configures... | Required by linters and API docs |
+optional marker for optional fields | // +optional | Goes before the field |
| Slice fields declare list type | // +listType=map | Enables strategic merge patch |
Enum Types
type ProtocolType string
const (
ProtocolHTTP ProtocolType = "HTTP"
ProtocolHTTPS ProtocolType = "HTTPS"
ProtocolGRPC ProtocolType = "gRPC"
ProtocolGRPCS ProtocolType = "gRPCS"
)
Discriminated Unions
For fields where only one of several options should be set:
type AuthConfig struct {
APIKey *APIKeyAuth `json:"apiKey,omitempty"`
AWSCredentials *AWSAuth `json:"awsCredentials,omitempty"`
OIDCToken *OIDCAuth `json:"oidcToken,omitempty"`
}
Kubebuilder Markers
Object Markers
Validation Markers
Print Columns
CEL Validation (Over Webhooks)
Why CEL Over Webhooks
The Gateway API project successfully replaced all admission webhooks with CEL validation rules. Benefits:
- No infrastructure: No webhook server to deploy, certificate to manage, or availability concern
- Declarative: Rules live in the CRD schema, not external code
- Version-safe: Rules are part of the CRD manifest, not a running binary
- Performance: Evaluated in the API server, no network hop
Both Envoy Gateway and AI Gateway use CEL exclusively (no webhooks).
CEL Rule Patterns
Mutual Exclusivity
Exactly One Of N Fields
Conditional Required Fields
Cross-Field Consistency
Immutable Fields
Note: Rules using oldSelf (transition rules) must be placed on the type definition that contains the field, not on the field itself. The oldSelf variable is only available in type-level XValidation markers, not field-level markers. Example:
type MyResourceSpec struct {
Name string `json:"name"`
}
Real-World CEL Examples
From AI Gateway — BackendSecurityPolicy auth type mutual exclusivity:
Testing CEL Rules
CEL rules are tested with envtest (real API server):
func TestAuthConfigCELValidation(t *testing.T) {
tests := []struct {
name string
obj *myv1.MyResource
wantError bool
errMsg string
}{
{
name: "valid: exactly one auth type",
obj: &myv1.MyResource{
ObjectMeta: metav1.ObjectMeta{
Name: "test-valid-auth",
Namespace: "default",
},
Spec: myv1.MyResourceSpec{
Auth: &myv1.AuthConfig{
APIKey: &myv1.APIKeyAuth{SecretRef: "my-secret"},
},
},
},
},
{
name: "invalid: two auth types set",
obj: &myv1.MyResource{
ObjectMeta: metav1.ObjectMeta{
Name: "test-invalid-two-auth",
Namespace: "default",
},
Spec: myv1.MyResourceSpec{
Auth: &myv1.AuthConfig{
APIKey: &myv1.APIKeyAuth{SecretRef: "my-secret"},
AWSCredentials: &myv1.AWSAuth{Region: "us-east-1"},
},
},
},
wantError: true,
errMsg: "exactly one auth type",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := k8sClient.Create(t.Context(), tc.obj)
if tc.wantError {
require.Error(t, err)
require.Contains(t, err.Error(), tc.errMsg)
} else {
require.NoError(t, err)
}
})
}
}
Code Generation
Pipeline
controller-gen object crd paths="./api/..." output:crd:dir=config/crd/bases
make apigen
make generate
make codegen
make apidoc
Generated Files
| File | Generator | Purpose |
|---|
zz_generated.deepcopy.go | controller-gen object | DeepCopy/DeepCopyInto/DeepCopyObject methods |
config/crd/bases/*.yaml | controller-gen crd | CRD YAML manifests with OpenAPI schema |
The two files above are required for all controller-runtime projects.
The following generators are used by Envoy Gateway but are not needed for most new controller-runtime projects — controller-runtime's client.Client handles typed operations directly:
| File | Generator | Purpose |
|---|
*_client.go | client-gen | Typed Kubernetes clients (used by Envoy Gateway) |
*_lister.go | lister-gen | Resource listers |
*_informer.go | informer-gen | Shared informers |
Why EG uses these: Envoy Gateway adopted these generators early in its development for typed client access patterns that predated controller-runtime's improvements. They remain in use because EG's translation pipeline uses typed listers for efficient resource lookups during IR construction. AI Gateway, started later, uses only controller-runtime's client.Client and does not need these generators.
When to Regenerate
Always regenerate after:
- Adding or modifying a type in
*_types.go
- Changing kubebuilder markers
- Adding or removing fields
- Modifying validation rules
Verify: git diff should show changes in generated files matching your type changes.
Versioning Strategy
Hub-Spoke Pattern
For multi-version CRDs, designate one version as the "hub" (storage version) and implement conversion between hub and spoke versions:
type MyResourceV1 struct { ... }
type MyResourceV1Beta1 struct { ... }
func (src *MyResourceV1Beta1) ConvertTo(dstRaw conversion.Hub) error {
dst := dstRaw.(*MyResourceV1)
return nil
}
func (dst *MyResourceV1Beta1) ConvertFrom(srcRaw conversion.Hub) error {
src := srcRaw.(*MyResourceV1)
return nil
}
Version Graduation
- v1alpha1 → experimental, breaking changes expected
- v1beta1 → feature-complete, API may change with deprecation notice
- v1 → stable, backward-compatible changes only
When graduating:
- Add the new version as hub
- Keep the old version as spoke with conversion
- Mark old version as deprecated with
+kubebuilder:deprecatedversion
- Set
+kubebuilder:storageversion on the new version
API Design Rules
Do
- Use
nil to mean "not configured" — don't add a field if omitted behavior is the same as default
- Align defaults with upstream project defaults (Envoy, Gateway API)
- Use enum types with
+kubebuilder:validation:Enum for fixed value sets
- Flatten types when nesting adds no semantic meaning
- Follow existing naming patterns in the same CRD
- Add CEL validation for mutually exclusive or cross-field constraints
- Separate API PRs from implementation PRs
- Use
metav1.Duration for duration fields (not raw string or int)
- Use
resource.Quantity for resource amounts
Don't
- Add defaults that differ from upstream without documenting why
- Use raw strings where enums are appropriate
- Create deeply nested types (more than 2 levels)
- Add fields without doc comments
- Skip CEL tests for validation rules
- Mix API changes with implementation in the same PR
- Use
map[string]string for structured data (use typed structs)
- Add boolean fields (prefer enums for future extensibility)
Checklist