Skip to main content

CRDs and Derive Macros

#[derive(CustomResource)] generates an entire Kubernetes Custom Resource Definition from a single Rust struct. Understanding what code this macro actually produces and how the schema is generated helps you debug CRD-related issues.

Input Code

#[derive(CustomResource, Clone, Debug, Serialize, Deserialize, JsonSchema)]
#[kube(group = "example.com", version = "v1", kind = "Document")]
#[kube(namespaced, status = "DocumentStatus")]
pub struct DocumentSpec {
pub title: String,
pub content: String,
}

#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
pub struct DocumentStatus {
pub phase: String,
}

All you define is DocumentSpec (and optionally DocumentStatus). The macro generates everything else.

Generated Code

You can inspect what #[derive(CustomResource)] produces using cargo expand.

1. Document Struct

Generated code (simplified)
pub struct Document {
pub metadata: ObjectMeta,
pub spec: DocumentSpec,
pub status: Option<DocumentStatus>,
}

The user-defined DocumentSpec becomes the spec field and DocumentStatus becomes the status field. metadata is always ObjectMeta.

2. Resource Trait Implementation

Generated code (simplified)
impl Resource for Document {
type DynamicType = ();
type Scope = NamespaceResourceScope; // #[kube(namespaced)]

fn kind(_: &()) -> Cow<'_, str> { "Document".into() }
fn group(_: &()) -> Cow<'_, str> { "example.com".into() }
fn version(_: &()) -> Cow<'_, str> { "v1".into() }
fn plural(_: &()) -> Cow<'_, str> { "documents".into() }
fn meta(&self) -> &ObjectMeta { &self.metadata }
fn meta_mut(&mut self) -> &mut ObjectMeta { &mut self.metadata }
}

Without #[kube(namespaced)], it becomes Scope = ClusterResourceScope.

3. CustomResourceExt Implementation

Generated code (simplified)
impl CustomResourceExt for Document {
fn crd() -> CustomResourceDefinition { /* Generates the full CRD structure */ }
fn crd_name() -> &'static str { "documents.example.com" }
fn api_resource() -> ApiResource { /* Generates ApiResource */ }
fn shortnames() -> &'static [&'static str] { &[] }
}

4. Other Implementations

  • HasSpec for Document -- fn spec(&self) -> &DocumentSpec
  • HasStatus for Document -- fn status(&self) -> Option<&DocumentStatus> (when status is specified)
  • Document::new(name, spec) -- Convenience function for creating new instances

Schema Generation Process

CRD schema generation goes through three steps:

  1. #[derive(JsonSchema)] (schemars) on DocumentSpec generates an OpenAPI v3 JSON schema
  2. kube-derive inserts this schema into the CRD's .spec.versions[].schema.openAPIV3Schema field
  3. Calling Document::crd() returns the completed CRD

Structure of the final CRD:

CRD generated by Document::crd()
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: documents.example.com
spec:
group: example.com
names:
kind: Document
plural: documents
singular: document
scope: Namespaced
versions:
- name: v1
served: true
storage: true
schema:
openAPIV3Schema:
properties:
spec:
properties:
title: { type: string }
content: { type: string }
required: [title, content]
status:
properties:
phase: { type: string }

Key #[kube(...)] Attributes

Required

AttributeDescription
group = "example.com"CRD API group
version = "v1"API version
kind = "Document"Resource kind

Scope and Subresources

#[kube(namespaced)]                     // Namespace-scoped (cluster-scoped without this)
#[kube(status = "DocumentStatus")] // Enable /status subresource
#[kube(scale = r#"{"specReplicasPath": ".spec.replicas", "statusReplicasPath": ".status.replicas"}"#)]

Metadata

#[kube(shortname = "doc")]              // kubectl get doc
#[kube(category = "example")] // kubectl get example (group lookup)
#[kube(printcolumn = r#"{"name":"Phase","type":"string","jsonPath":".status.phase"}"#)]
#[kube(selectable = ".spec.title")] // Field selector support

Schema Control

#[kube(schema = "derived")]             // Default: auto-generated from schemars
#[kube(schema = "manual")] // Manual schema specification
#[kube(schema = "disabled")] // Disable schema
#[kube(doc = "Document resource description")] // CRD description

CEL Validation

#[kube(validation = Rule::new("self.spec.title.size() > 0"))]

Performs CEL (Common Expression Language) validation on the Kubernetes server side.

Version Management

#[kube(storage)]                        // Store this version in etcd
#[kube(served = true)] // Serve via API
#[kube(deprecated = "Migrate to v2")] // Mark as deprecated

Schema Pitfalls

Untagged Enum

#[derive(Serialize, Deserialize, JsonSchema)]
#[serde(untagged)]
enum Value {
String(String),
Number(i64),
}

schemars generates an anyOf schema. Kubernetes may reject this because it does not recognize it as a structural schema.

Workaround: Use #[schemars(schema_with = "custom_schema")] to specify a manual schema.

Flatten HashMap

#[derive(Serialize, Deserialize, JsonSchema)]
struct Config {
name: String,
#[serde(flatten)]
extra: HashMap<String, serde_json::Value>,
}

schemars generates additionalProperties, which may not be compatible with the OpenAPI v3 schema.

ArgoCD Drift

kube-derive generates empty shortNames, categories, etc. as defaults. The Kubernetes API server strips these empty arrays, creating a discrepancy between the CRD stored in etcd and the CRD generated by Document::crd(). ArgoCD detects this as a permanent drift.

Debugging with cargo expand

To inspect the code generated by the macro, use cargo expand:

cargo expand --lib | grep -A 50 "impl Resource for Document"

CRD Registration Pattern

A pattern for registering a CRD via Server-Side Apply and waiting until it becomes active:

use kube::runtime::wait::conditions;

let crds = Document::crd();
let crd_api: Api<CustomResourceDefinition> = Api::all(client.clone());

// Register/update CRD via SSA
let pp = PatchParams::apply("my-controller").force();
crd_api.patch("documents.example.com", &pp, &Patch::Apply(crds)).await?;

// Wait until CRD reaches Established status
let establish = conditions::is_crd_established();
let crd = tokio::time::timeout(
Duration::from_secs(10),
kube::runtime::wait::await_condition(crd_api, "documents.example.com", establish),
).await??;

Other Derive Macros

#[derive(Resource)]

Implements the Resource trait on an existing type. Useful for structs that wrap k8s-openapi types.

#[derive(Resource, Clone, Debug, Serialize, Deserialize)]
#[resource(inherit = "ConfigMap")]
struct MyConfigMap {
metadata: ObjectMeta,
data: Option<BTreeMap<String, String>>,
}

#[derive(KubeSchema)]

Generates a JsonSchema implementation that includes CEL validation rules. Can be used together with CustomResource or standalone. For detailed CEL validation usage, see Admission Validation -- CEL Validation.

Schema Overrides

When the default schema generated by schemars does not meet Kubernetes structural schema requirements, you can override the schema on a per-field basis.

schemars(schema_with)

#[schemars(schema_with = "function_name")] completely replaces the schema for a specific field:

use schemars::schema::{Schema, SchemaObject, InstanceType};

fn quantity_schema(_gen: &mut schemars::SchemaGenerator) -> Schema {
Schema::Object(SchemaObject {
instance_type: Some(InstanceType::String.into()),
format: Some("quantity".to_string()),
..Default::default()
})
}

#[derive(CustomResource, KubeSchema, Serialize, Deserialize, Clone, Debug)]
#[kube(group = "example.com", version = "v1", kind = "MyApp")]
pub struct MyAppSpec {
#[schemars(schema_with = "quantity_schema")]
pub memory_limit: String,
}

This approach resolves the untagged enum and flatten HashMap issues discussed in Schema Pitfalls.

x-kubernetes-validations

The x-kubernetes-validations extension generated by #[x_kube(validation)] is an extension field of the OpenAPI schema:

Generated schema
properties:
title:
type: string
x-kubernetes-validations:
- rule: "self != ''"
message: "title must not be empty"

This extension field is evaluated by the API server's CEL engine. It operates independently of the schema's own type, format, and other fields.

CRD Version Management

Per-Version Modules

When serving a CRD in multiple versions, create separate modules for each version and apply #[derive(CustomResource)] to each:

mod v1 {
#[derive(CustomResource, KubeSchema, Serialize, Deserialize, Clone, Debug)]
#[kube(group = "example.com", version = "v1", kind = "Document")]
#[kube(namespaced, status = "DocumentStatus")]
pub struct DocumentSpec {
pub title: String,
}
}

mod v2 {
#[derive(CustomResource, KubeSchema, Serialize, Deserialize, Clone, Debug)]
#[kube(group = "example.com", version = "v2", kind = "Document")]
#[kube(namespaced, status = "DocumentStatus")]
pub struct DocumentSpec {
pub title: String,
pub category: String, // Added in v2
}
}

merge_crds()

merge_crds() combines multiple single-version CRDs into one multi-version CRD:

kube-core/src/crd.rs
pub fn merge_crds(crds: Vec<CustomResourceDefinition>, stored_apiversion: &str)
-> Result<CustomResourceDefinition, MergeError>
use kube::core::crd::merge_crds;

let merged = merge_crds(
vec![v1::Document::crd(), v2::Document::crd()],
"v2", // Version to store in etcd
)?;

// Register merged CRD with the API server
crd_api.patch("documents.example.com", &pp, &Patch::Apply(merged)).await?;

merge_crds() validates the following:

  • All CRDs have the same spec.group
  • All CRDs have the same spec.names.kind
  • All CRDs have the same spec.scope
  • Each input CRD is single-version

Only the version specified by stored_apiversion is set to storage: true; the rest are set to storage: false.

Version Conversion

When a request comes in for a version different from the stored version, Kubernetes performs conversion. Simple field additions/removals are handled automatically by the API server, but complex conversions require a conversion webhook.