Skip to content

Add a Third-Party CRD

Most operators ship their API as Kubernetes CustomResourceDefinitions. Because a CRD carries an openAPIV3Schema, the k8s lexicon can generate typed resources from it the same way it generates the built-in Kubernetes types. You do not write a new lexicon for KubeRay, Argo CD, cert-manager, or Crossplane — you add their CRDs to the k8s lexicon and, optionally, author rules against the kinds they introduce.

This is the path KubeRay, Argo CD, Gateway API, the CockroachDB operator, cert-manager, the Prometheus operator, CloudNativePG, Traefik, and Infisical all took. This page documents it.

Generation reads its third-party CRDs from one array, CRD_SOURCES in lexicons/k8s/src/crd/crd-sources.ts. Each entry names where a CRD document is fetched from at generation time:

export const CRD_SOURCES: CRDSource[] = [
// KubeRay — ray.io/v1
{ type: "url", url: `${KUBERAY_CRD_BASE}/ray.io_rayclusters.yaml` },
{ type: "url", url: `${KUBERAY_CRD_BASE}/ray.io_rayjobs.yaml` },
{ type: "url", url: `${KUBERAY_CRD_BASE}/ray.io_rayservices.yaml` },
// ...cert-manager, Argo, Gateway API, Prometheus, Cockroach
];

A CRDSource is one of four shapes:

ShapeFieldsUse
{ type: "url", url }a raw CRD YAML URLthe default — pin an operator release
{ type: "helm", chart, version, chartSubdir? }a chart referencean operator that ships CRDs only in its chart
{ type: "file", path }a local patha CRD vendored into the repo
{ type: "cluster", context?, namespace? }a kubectl contextread CRDs already installed in a cluster

Pin the operator version in a const next to the entry (see the KUBERAY_VERSION / ARGOCD_VERSION constants at the top of crd-sources.ts) so an upgrade is a one-line change and the source URL always resolves to a known revision.

Operators built with the Java and Go operator SDKs generate their CRDs at build time from the controller’s types, so there is often nothing to link to. KubeMicroVM commits one of its five CRDs and publishes all five in its chart.

type: "helm" pulls and unpacks the chart at generation time and reads every YAML document out of its crds/ directory, which is where Helm’s own convention puts them:

{
type: "helm",
chart: "oci://ghcr.io/codriverlabs/helm/kube-microvm-operator",
version: "1.0.11",
kinds: ["MicroVM", "MicroVMImage", "MicroVMNetwork", "MicroVMClass", "MicroVMReplicaSet"],
}

version is required, because an unpinned chart makes generated output depend on the day it was generated. chartSubdir overrides crds for charts that keep them somewhere else. The kinds allowlist works exactly as it does for a multi-doc URL bundle.

This needs the helm binary on PATH at generation time, the same shape of dependency type: "cluster" has on kubectl. Prefer url when the operator publishes its CRDs — it is one fewer tool and one fewer network shape.

The CRD YAML is fetched and baked in at generation time, not at build time. chant build stays offline and deterministic — see Network Egress for the guard that keeps that true.

From the k8s lexicon package, run generation. It fetches each source, parses the schema, and emits the typed surface:

Terminal window
npm run generate -w @intentius/chant-lexicon-k8s

Generation writes three artifacts into src/generated/: index.d.ts (the types), index.ts (the runtime factory index), and lexicon-k8s.json (the registry). The log line reports the counts and any CRD it failed to load.

Each CRD kind becomes a resource type K8s::<Namespace>::<Kind>. The namespace comes from the CRD’s API group: the first segment of the group is PascalCased, with a small override table for groups whose first segment reads badly.

CRD groupNamespaceExample type
ray.ioRayK8s::Ray::RayCluster
cert-manager.ioCertManagerK8s::CertManager::Certificate
monitoring.coreos.comMonitoringK8s::Monitoring::ServiceMonitor
argoproj.ioArgo (override)K8s::Argo::Application
lambda.aws.amazon.comKubeMicroVM (override)K8s::KubeMicroVM::MicroVM
secrets.infisical.comInfisical (override)K8s::Infisical::InfisicalSecret
postgresql.cnpg.ioCnpg (override)K8s::Cnpg::Cluster
barmancloud.cnpg.ioCnpg (override)K8s::Cnpg::ObjectStore

An override earns its place for one of two reasons. Either the first segment reads badly on its own — secrets.infisical.com would give K8s::Secrets::InfisicalSecret, which looks like a core Secret when K8s::Core::Secret is right there — or several groups belong together. The last two rows are the second case: a CNPG Cluster names a barman-cloud ObjectStore in its plugins[] block, so splitting them into Postgresql and Barmancloud would scatter two kinds that are only ever used as a pair. Flux does the same with six groups.

The mapping lives in normalizeGroupName / GROUP_NAMESPACE_OVERRIDES in lexicons/k8s/src/crd/parser.ts. Add an override there if a group’s first segment collides or reads poorly.

The generator types the writable spec. It deliberately drops apiVersion, kind, and status (see extractProperties in crd/parser.ts) — status is runtime state, not authoring input.

Confirm the kind you added is present and the package still builds:

Terminal window
grep -R "RayCluster" src/generated/index.d.ts # the type exists
npm run build -w @intentius/chant-lexicon-k8s # the lexicon compiles

Then import it in a scratch file and check that editor completion resolves the spec — that is the fastest confirmation the schema round-tripped.

A CRD source is one CRD document. Some operators ship hundreds — a full Crossplane provider-aws is on the order of a thousand kinds. Generating all of them at once produces an enormous types file and slow type-checking (the same failure mode the Azure codegen hit). Add the specific kinds a project uses, or scope by resource group. Never point a source at an operator’s entire CRD bundle without a reason.

Types give you structural validation for free — a misspelled field or a wrong type will not compile. Everything semantic is a rule you author against the kind by name. The kind being CRD-derived changes nothing; rules target it exactly as they target a built-in resource.

Argo CD is the worked precedent. It arrives through CRD_SOURCES and carries a full rule set:

  • Pre-synth (source AST) — ARGO001 in lint/rules/argo-automated-prune.ts flags a production Application that enables syncPolicy.automated.prune. Wire a pre-synth rule by importing it into the lintRules() array in lexicons/k8s/src/plugin.ts.
  • Post-synth (emitted manifests) — ARGO003 in lint/post-synth/argo003.ts checks each Application.spec.destination against the cluster Secrets in the same output. Drop a post-synth check file in lint/post-synth/ and it is discovered automatically; no manual registration.

The post-synth cross-resource pattern — collect every manifest of kind A, validate each kind B against them — is the one worth studying, because XR-against-XRD conformance and Composition-references-a-real-kind checks are the same shape. See Post-Synth Checks for that pattern in full.