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
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
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
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) -> &DocumentSpecHasStatus 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:
#[derive(JsonSchema)](schemars) onDocumentSpecgenerates an OpenAPI v3 JSON schema- kube-derive inserts this schema into the CRD's
.spec.versions[].schema.openAPIV3Schemafield - Calling
Document::crd()returns the completed CRD
Structure of the final 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
| Attribute | Description |
|---|---|
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.
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:
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:
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.
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.