Skip to content

Add a Third-Party CRD

llms.txt lists every page for an agent
Optional: hand this page to your coding agentThe steps work by hand too.
Show the whole prompt
Read https://intentius.io/chant/lexicon-authoring/crd-sources/.
Add the CRDs of the operator I name to `CRD_SOURCES` in `lexicons/k8s/src/crd/crd-sources.ts`, pinned to a release in a version const and kept to the kinds a project uses, with the doc comment the other entries carry.
Run `npm run generate -w @intentius/chant-lexicon-k8s` and `npm run build -w @intentius/chant-lexicon-k8s`, check that each kind appears in `lexicons/k8s/src/generated/index.d.ts`, and open a pull request with the result.
Do not point a source at a whole provider bundle, and do not set `CHANT_K8S_ALLOW_CRD_FAILURES`.
Never deploy to a real cloud account or shared environment (with `chant run`, `chant components` or a platform's own CLI), approve (a pull request review, `chant approve` or `chant run approve`), override a policy denial (`chant approve policy-override`), or merge; never cut or publish a release (`chant release cut --yes` or `chant release publish`), push or delete a `ci/*` tag (`chant ci tick` pushes them), or change the value of an existing flag; never touch `.chant/allowed_signers` or `chant/lifecycle`.

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. For KubeRay, Argo CD, cert-manager or Crossplane, add their CRDs to the k8s lexicon and, optionally, author rules against the kinds they introduce.

Every third-party API in the k8s lexicon arrived this way, from KubeRay and Argo CD to the ACK controllers. Read CRD_SOURCES for what is there today. This page shows how to add to 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[] = [
{ 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, Flux, Gateway API, Prometheus, Cockroach, ACK
];

The CRDSource type is in lexicons/k8s/src/crd/types.ts; loadCRDs in lexicons/k8s/src/crd/loader.ts fetches each one. Each block in crd-sources.ts is preceded by a doc comment naming the operator, its API group, the kinds it produces and the install command for its controller. Follow that shape when you add one.

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, digest?, 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

Every shape also takes an optional kinds allowlist, which keeps a multi-document bundle down to the kinds you want.

Pin the operator version in a const next to the entry (see the KUBERAY_VERSION and 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",
digest: "sha256:b15570736277f69bc69bc6bd1d6b89470404b8bbd45970b7dcb29ca9ab69170c",
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 as it does for a multi-doc URL bundle.

An oci:// chart is pulled over the registry’s HTTP API with an anonymous token and unpacked in process, so generation does not need helm on PATH and produces the same surface on a fresh CI runner as on a workstation. digest pins the chart’s content layer (the sha256: the registry manifest lists for the application/vnd.cncf.helm.chart.content.v1.tar+gzip layer); with it, a re-pushed tag fails generation instead of changing the output. A classic https:// chart repository still needs the helm binary. Prefer url when the operator publishes its CRDs, since it is 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. A source that fails to load (a registry that is down, a digest that no longer matches) fails generation, and the error names every such source, so a missing operator cannot drop its kinds from the output unnoticed. To generate without them on purpose, for example offline, set CHANT_K8S_ALLOW_CRD_FAILURES=1: each failure is then a warning and the output lacks that source’s kinds.

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 next to K8s::Core::Secret), 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 generator types the writable spec and metadata. extractProperties in crd/parser.ts skips apiVersion and kind (the serializer supplies both) and skips status, which extractStatusType re-attaches as a read-only status attribute instead of an authoring property.

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

Terminal window
grep -R "RayCluster" lexicons/k8s/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: a misspelled field or a wrong type will not compile. Everything semantic is a rule you author against the kind by name. Rules target a CRD-derived kind 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.

The post-synth cross-resource pattern (collect every manifest of kind A, validate each kind B against them) is the one to study, 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.

chant