Custom resources
Define a typed resource, generate its CRD, then use it in a controller
or attach admission policies. Application includes decorated primary
models in its manifests; its install command applies their CRDs. Importing a model
or starting run does not install a CRD.
Define and generate
from typing import Annotated, Literal
from pydantic import Field
from cloudcoil.controller import ReconcileStatus
from cloudcoil.crd import CRD, PrinterColumn, custom_resource
from cloudcoil.pydantic import BaseModel
from cloudcoil.resources import Resource
class WidgetSpec(BaseModel):
message: str = Field(min_length=1, max_length=200)
class WidgetStatus(ReconcileStatus):
ready_replicas: Annotated[int, PrinterColumn(name="Ready replicas")] = 0
@custom_resource(api_version="examples.cloudcoil.dev/v1alpha1", plural="widgets")
class Widget(Resource):
spec: WidgetSpec
status: WidgetStatus | None = None
print(CRD(Widget).to_yaml()) # Offline; does not install anything.
The decorator sets validated apiVersion and kind fields; kind defaults to the
class name. Explicit Literal fields are also supported when omitting api_version
from the decorator. Explicit plural names avoid guessing English plurals. Keep the apiVersion alias when overriding api_version, and use
aliases such as observedGeneration for fields whose Kubernetes names differ from
Python names. Generation checks that validation and serialization agree on names.
PrinterColumn on an Annotated field infers its serialized JSONPath and scalar
type, including nested models and aliases; a date-time field becomes a date
column. Inferred columns are followed by the default Age column. Explicit
CRD(Widget, columns=[...]) replaces them; columns=[] disables all columns.
Collection-item columns need an explicit json_path.
The class decorator preserves the Pydantic model and supplies its wire identity and CRD metadata. It does
not create a controller, install the CRD, or register a global webhook. Each concrete
subclass declares its own plural. Existing models can still use
CRD(Widget, plural="widgets", ...); constructor options override class metadata.
The generator emits one served/storage version in
apiextensions.k8s.io/v1. It enables the status subresource when the model has a
status field, with an explicit override available. Keep status optional with a
None default: the API server removes status during normal creates. A required
status field would prevent initial creation. Scope and CRD installation permissions
are independent of a controller's watch namespace.
CRD generation does not install anything. Review the emitted manifest and apply it
with your normal deployment workflow. Updating an existing CRD is an API change:
consider stored objects and compatibility before narrowing its schema. Multiple
served versions, conversion webhooks and storage-version migration are unsupported.
Status for controllers
ReconcileStatus supplies standard conditions and observedGeneration. A controller
using this status model automatically reports Ready; stages add their named conditions.
Additional fields need defaults so an absent status can be initialized. Keep the
resource's status optional, as in the example above.
Use ctx.set_status(ready_replicas=2) inside a handler. The helper validates fields
and queues a guarded status write. Use an ordinary BaseModel status when another
component owns reporting, or disable automatic reporting with report_status=False.
See status helpers for failure behavior and
condition ownership.
Use a handwritten resource as a client
Every Resource subclass inherits the same get, list, watch, create, update,
patch, status, and delete methods as generated Kubernetes models. The return types
remain your concrete resource type. No decorator or code-generation step is needed
for these methods; the CRD must be installed and discoverable by the API server.
Use the resource's typed client when you have an explicit Config, such as in an
operator or admission handler:
client = await Widget.async_client(config, namespace="team-a", cached=False)
widget = await client.get("example") # Widget
widgets = await client.list() # ResourceList[Widget]
widget.spec.message = "Updated"
widget = await client.update(widget)
Widget.client(config) is the synchronous equivalent. Omitting config uses the
active configuration, like Widget.get(...) and widget.async_update_status().
The optional namespace override belongs to the returned client and does not alter
the shared configuration. cached= follows Config.client_for semantics. The
configuration owns the transports and their lifetime; creating a resource client
does not create or close a separate connection pool. Status and scale operations
still require the corresponding server-side subresources.
Handwritten cloudcoil.pydantic.BaseModel and Resource subclasses also have
runtime .builder(), .new(), and .list_builder() helpers. Nested model fields,
optional models, and lists of models support callbacks and context managers,
including generated ObjectMeta builders:
widget = (
Widget.builder()
.metadata(lambda meta: meta.name("example"))
.spec(lambda spec: spec.message("Hello"))
.build()
)
These dynamic field setters validate at build() time. Their field signatures
are not available to static type checkers: use ordinary typed constructors for
handwritten models when you need field completion and static argument checking.
Generated models keep their existing fully typed builders. Builder chains are
immutable outside with Model.new() contexts; a failed nested context does not
commit a partial model. Ambiguous unions accept an explicit model value rather
than guessing which model to construct.
Schema behavior
The generator translates Pydantic validation schemas into Kubernetes structural
OpenAPI schemas. Nested models are inlined, nullable fields use nullable, constants
become enums, and supported numeric/string/list bounds remain validations. Typed
maps and lists retain their item schemas. Explicit arbitrary JSON fields preserve
unknown values; ordinary object schemas are pruned by Kubernetes according to their
schema. See Kubernetes structural schemas and pruning.
Unsupported constructs raise SchemaError with the field path. Recursive models,
ambiguous unions, and constraints that Kubernetes cannot represent are not silently
converted into permissive schemas. Python validators are not exported: use a
validation webhook for Python business rules, or explicit Kubernetes CEL schema
extensions when appropriate. Pydantic may coerce input during local validation;
Kubernetes's schema validation does not promise the same coercions.
Use regular Pydantic Field annotations for bounds and aliases. CEL and
ListType express common Kubernetes-only extensions next to the type:
from cloudcoil.crd import CEL, ListType
class Condition(BaseModel):
type: str
status: Literal["True", "False", "Unknown"]
class ExampleSpec(BaseModel):
replicas: Annotated[int, Field(ge=0), CEL("self <= 10", "At most ten replicas")] = 1
conditions: Annotated[list[Condition], ListType("map", keys=("type",))] = Field(
default_factory=list
)
ListType("set") and ListType("atomic") are also supported. Map keys use wire
names. CEL runs in Kubernetes, not in Pydantic; Python field_validator and
model_validator still apply when admission parses the typed resource.
Field(json_schema_extra=...) remains available for other Kubernetes extensions.
These must still be valid for the field's Kubernetes schema. Generation is not a CEL compiler; test your CRD
against the API-server versions you support. The repository's integration test
installs generated CRDs and checks schema validation and status operations.