CRD-Generated Classes
The k8s lexicon generates typed classes from the core Kubernetes OpenAPI spec plus a curated list of third-party CRDs. Each CRD in the list becomes a first-class K8s::* resource — with the serializer, LSP hover, and MCP — the same as a built-in Deployment. No hand-written YAML, no as any. The constructor’s spec is an open record; its fields are checked against the CRD schema at lint time (see Spec validation).
How it works
Section titled “How it works”CRD sources live in lexicons/k8s/src/crd/crd-sources.ts. At generation time (npm run generate) the CRD YAML is fetched from a pinned upstream URL, parsed, and baked into the lexicon output. The group’s first segment maps to a TypeScript namespace via the first-segment rule in crd/parser.ts — e.g. gateway.networking.k8s.io → Gateway, cert-manager.io → CertManager.
group namespace example class───────────────────────────── ─────────────── ─────────────────────────ray.io Ray K8s::Ray::RayClusterargoproj.io Argo K8s::Argo::Applicationgateway.networking.k8s.io Gateway K8s::Gateway::HTTPRoutecrdb.cockroachlabs.com Crdb K8s::Crdb::CrdbClusterpostgresql.cnpg.io Cnpg K8s::Cnpg::Clusterbarmancloud.cnpg.io Cnpg K8s::Cnpg::ObjectStoretraefik.io Traefik K8s::Traefik::IngressRoutecert-manager.io CertManager K8s::CertManager::Certificateacme.cert-manager.io Acme K8s::Acme::Challengesecrets.infisical.com Infisical K8s::Infisical::InfisicalSecret*.toolkit.fluxcd.io Flux K8s::Flux::Kustomizationfluxcd.controlplane.io Flux K8s::Flux::FluxInstanceThe Flux and CNPG rows show the override at work: six distinct groups all map to one Flux namespace (see Flux), and CNPG’s two groups both map to Cnpg, rather than each group’s first segment.
Spec validation
Section titled “Spec validation”A generated CRD class takes spec: Record<string, unknown>. The field names, scalar types and enums from the CRD’s openAPIV3Schema do not reach the TypeScript constructor; they ship in the lexicon JSON instead, as a specSchema on each custom resource’s entry. Two post-synth checks read it at chant lint time:
WK8501flags aspecfield the CRD does not declare, with a “did you mean” suggestion. The API server would prune it and the controller would never see it.WK8502flags a scalar with the wrong type (replicas: "2") or a value outside its enum (desiredState: "Runing").
Objects the CRD marks x-kubernetes-preserve-unknown-fields or additionalProperties accept any member; x-kubernetes-int-or-string accepts either. Nothing checks a built-in kind this way, because the generated .d.ts already does.
Gateway API
Section titled “Gateway API”gateway.networking.k8s.io, pinned to v1.2.1 (standard channel). The modern, portable replacement for Ingress — GRPCRoute in particular is the native way to express a gRPC route, instead of ingress-controller annotations.
| Type | apiVersion / kind |
|---|---|
GatewayClass | gateway.networking.k8s.io/v1 / GatewayClass |
Gateway | gateway.networking.k8s.io/v1 / Gateway |
HTTPRoute | gateway.networking.k8s.io/v1 / HTTPRoute |
GRPCRoute | gateway.networking.k8s.io/v1 / GRPCRoute |
ReferenceGrant | gateway.networking.k8s.io/v1beta1 / ReferenceGrant |
kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.2.1/standard-install.yamlimport { Gateway, HTTPRoute } from "@intentius/chant-lexicon-k8s";
export const gw = new Gateway({ metadata: { name: "edge", namespace: "infra" }, spec: { gatewayClassName: "istio", listeners: [{ name: "http", protocol: "HTTP", port: 80 }], },});
export const route = new HTTPRoute({ metadata: { name: "api", namespace: "infra" }, spec: { parentRefs: [{ name: "edge" }], rules: [{ backendRefs: [{ name: "api", port: 8080 }] }], },});cert-manager
Section titled “cert-manager”cert-manager.io + acme.cert-manager.io, pinned to v1.16.2. The de-facto controller for issuing and rotating TLS certificates. The bundle is a single multi-doc YAML.
| Type | apiVersion |
|---|---|
Certificate | cert-manager.io/v1 |
CertificateRequest | cert-manager.io/v1 |
Issuer | cert-manager.io/v1 |
ClusterIssuer | cert-manager.io/v1 |
Challenge | acme.cert-manager.io/v1 |
Order | acme.cert-manager.io/v1 |
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.16.2/cert-manager.yamlimport { ClusterIssuer, Certificate } from "@intentius/chant-lexicon-k8s";
export const issuer = new ClusterIssuer({ metadata: { name: "letsencrypt-prod" }, spec: { acme: { server: "https://acme-v02.api.letsencrypt.org/directory", email: "ops@acme.io", privateKeySecretRef: { name: "letsencrypt-prod" }, solvers: [{ http01: { ingress: { class: "nginx" } } }], }, },});
export const cert = new Certificate({ metadata: { name: "api-tls", namespace: "api" }, spec: { secretName: "api-tls", issuerRef: { name: "letsencrypt-prod", kind: "ClusterIssuer" }, dnsNames: ["api.acme.io"], },});SecureIngress emits a real Certificate
Section titled “SecureIngress emits a real Certificate”When you set clusterIssuer on the SecureIngress composite, it now emits a genuine K8s::CertManager::Certificate (wired to the named issuer) alongside the ingress-shim annotation — no placeholder.
import { SecureIngress } from "@intentius/chant-lexicon-k8s";
export const ingress = SecureIngress("api", { host: "api.acme.io", serviceName: "api", servicePort: 8080, clusterIssuer: "letsencrypt-prod", // → annotation + a real Certificate});CloudNativePG
Section titled “CloudNativePG”postgresql.cnpg.io, pinned to v1.29.1, plus the barman-cloud plugin’s barmancloud.cnpg.io, pinned to v0.14.0. The Postgres operator: it runs the primary/replica topology, failover, and backups.
Both groups map to the Cnpg namespace, because a Cluster and an ObjectStore are only useful together.
| Type | apiVersion / kind |
|---|---|
Cluster | postgresql.cnpg.io/v1 / Cluster |
ScheduledBackup | postgresql.cnpg.io/v1 / ScheduledBackup |
Backup | postgresql.cnpg.io/v1 / Backup |
Pooler | postgresql.cnpg.io/v1 / Pooler |
ObjectStore | barmancloud.cnpg.io/v1 / ObjectStore |
The pin is 1.29.1 rather than latest because that is the operator version a real consumer runs.
kubectl apply --server-side -f https://raw.githubusercontent.com/cloudnative-pg/cloudnative-pg/release-1.29/releases/cnpg-1.29.1.yamlkubectl apply -f https://github.com/cloudnative-pg/plugin-barman-cloud/releases/download/v0.14.0/manifest.yamlA cluster that archives WAL to object storage, and the nightly base backup that makes point-in-time recovery possible:
import { Cluster, ObjectStore, ScheduledBackup } from "@intentius/chant-lexicon-k8s";
export const store = new ObjectStore({ metadata: { name: "pg-backups", namespace: "app" }, spec: { retentionPolicy: "14d", configuration: { destinationPath: "s3://pg-backups/barman", s3Credentials: { accessKeyId: { name: "backup-s3", key: "AWS_ACCESS_KEY_ID" }, secretAccessKey: { name: "backup-s3", key: "AWS_SECRET_ACCESS_KEY" }, }, }, },});
export const db = new Cluster({ metadata: { name: "pg", namespace: "app" }, spec: { instances: 3, storage: { size: "10Gi" }, // Names the ObjectStore above. Nothing checks this string, so a typo // leaves the cluster healthy with archiving pointed at nothing. plugins: [{ name: "barman-cloud.cloudnative-pg.io", isWALArchiver: true, parameters: { barmanObjectName: "pg-backups" }, }], },});
export const nightly = new ScheduledBackup({ metadata: { name: "pg-base", namespace: "app" }, spec: { schedule: "0 47 2 * * *", // six fields, see below cluster: { name: "pg" }, method: "plugin", pluginConfiguration: { name: "barman-cloud.cloudnative-pg.io" },<Aside type="caution">`ScheduledBackup.schedule` takes **six** fields, leading with seconds — not the five a Kubernetes `CronJob` takes. `"0 47 2 * * *"` above is 02:47 daily.
Write the five-field form out of habit — `"47 2 * * *"`, meaning 02:47 to every other cron on the cluster — and CNPG reads it as second 47 of minute 2 of *every hour*. The schema types the field as a plain string, so nothing rejects it. You get 24 base backups a day and no error anywhere.</Aside>
<Aside type="note">Adding the `plugins` block to a live Cluster injects the plugin sidecar, which **rolls the Postgres pods**. At `instances: 1` that is a brief outage.</Aside>
## Traefik
`traefik.io`, pinned to chart **v41.1.0**. Traefik's own routing surface. An `IngressRoute` is not a `networking.k8s.io` `Ingress` with annotations — it is a separate CRD with its own matcher grammar, so before this an estate fronted by Traefik had an edge chant could not express.
| Type | apiVersion / kind ||---|---|| `IngressRoute` | `traefik.io/v1alpha1` / `IngressRoute` || `IngressRouteTCP` | `traefik.io/v1alpha1` / `IngressRouteTCP` || `IngressRouteUDP` | `traefik.io/v1alpha1` / `IngressRouteUDP` || `Middleware` | `traefik.io/v1alpha1` / `Middleware` || `MiddlewareTCP` | `traefik.io/v1alpha1` / `MiddlewareTCP` || `ServersTransport` | `traefik.io/v1alpha1` / `ServersTransport` || `ServersTransportTCP` | `traefik.io/v1alpha1` / `ServersTransportTCP` || `TLSOption` | `traefik.io/v1alpha1` / `TLSOption` || `TLSStore` | `traefik.io/v1alpha1` / `TLSStore` || `TraefikService` | `traefik.io/v1alpha1` / `TraefikService` |
Only the `traefik.io` group. The chart also ships `hub.traefik.io_*` — Traefik Hub, a different commercial product — and a vendored copy of the Gateway API, which is already generated here from its own upstream and would collide.
```bashhelm repo add traefik https://traefik.github.io/chartshelm install traefik traefik/traefik --version 41.1.0An HTTPS route plus the HTTP one that redirects to it:
import { IngressRoute, Middleware } from "@intentius/chant-lexicon-k8s";
export const redirect = new Middleware({ metadata: { name: "redirect-https", namespace: "default" }, spec: { redirectScheme: { scheme: "https", permanent: true } },});
export const secure = new IngressRoute({ metadata: { name: "app", namespace: "app" }, spec: { entryPoints: ["websecure"], routes: [{ match: "Host(`app.example.com`)", kind: "Rule", services: [{ name: "app", port: 80 }], }], tls: { secretName: "app-tls" }, },});
export const plain = new IngressRoute({ metadata: { name: "app-http", namespace: "app" }, spec: { entryPoints: ["web"], routes: [{ match: "Host(`app.example.com`)", kind: "Rule", // The middleware is in `default`, this route is in `app`. middlewares: [{ name: "redirect-https", namespace: "default" }], services: [{ name: "app", port: 80 }], }], },});Infisical
Section titled “Infisical”secrets.infisical.com, pinned to operator v0.11.7. Binds a Kubernetes Secret to an external secret provider.
| Type | apiVersion / kind |
|---|---|
InfisicalSecret | secrets.infisical.com/v1alpha1 / InfisicalSecret |
InfisicalPushSecret | secrets.infisical.com/v1alpha1 / InfisicalPushSecret |
InfisicalDynamicSecret | secrets.infisical.com/v1alpha1 / InfisicalDynamicSecret |
The group is overridden to Infisical. The first-segment rule would give K8s::Secrets::InfisicalSecret, which reads like a core Secret with K8s::Core::Secret right there to be confused with.
helm repo add infisical https://dl.cloudsmith.io/public/infisical/helm-charts/helm/charts/helm install infisical-secrets-operator infisical/secrets-operatorimport { InfisicalSecret } from "@intentius/chant-lexicon-k8s";
export const appSecrets = new InfisicalSecret({ metadata: { name: "app-secrets", namespace: "app" }, spec: { hostAPI: "http://infisical.infisical.svc.cluster.local:8080", resyncInterval: 60, authentication: { kubernetesAuth: { identityId: "<machine identity>", autoCreateServiceAccountToken: true, serviceAccountRef: { name: "app-infisical", namespace: "app" }, secretsScope: { projectSlug: "app", envSlug: "prod", secretsPath: "/" }, }, }, managedSecretReference: { secretName: "app-secrets", // a Deployment envFrom's this secretNamespace: "app", creationPolicy: "Orphan", }, },});Every field there is policy: where the secret comes from, who may fetch it, what happens to the materialized Secret when the CR is deleted. None of it is a value — which is exactly why this kind is expressible in chant. Synthesis resolves nothing, so the manifest stays safe to commit. See Where Values Come From.
CockroachDB operator
Section titled “CockroachDB operator”crdb.cockroachlabs.com, pinned to v2.17.0. The operator-managed path for a CockroachDB cluster — the operator handles version upgrades, scale-down decommissioning, and cert rotation.
| Type | apiVersion / kind |
|---|---|
CrdbCluster | crdb.cockroachlabs.com/v1alpha1 / CrdbCluster |
kubectl apply -f https://github.com/cockroachdb/cockroach-operator/releases/download/v2.17.0/install/operator.yamlimport { CrdbCluster } from "@intentius/chant-lexicon-k8s";
export const db = new CrdbCluster({ metadata: { name: "cockroachdb", namespace: "crdb" }, spec: { dataStore: { pvc: { spec: { resources: { requests: { storage: "60Gi" } } } } }, tlsEnabled: true, image: { name: "cockroachdb/cockroach:v24.1.0" }, nodes: 3, },});KubeRay & Argo CD
Section titled “KubeRay & Argo CD”The first two CRD sources. KubeRay (ray.io, v1.3.0) produces RayCluster, RayJob, and RayService. Argo CD (argoproj.io, v2.13.3) produces Application, ApplicationSet, and AppProject. Both ship value-add composites — see Argo CD Composites and the Ray + KubeRay on GKE tutorial.
The Flux GitOps Toolkit (pinned to flux2 v2.9.1) and the Flux Operator (v0.54.1). The toolkit spreads across five *.toolkit.fluxcd.io groups and the operator adds fluxcd.controlplane.io; all six collapse to a single Flux namespace, so a GitRepository and a Kustomization read as K8s::Flux::* siblings rather than scattering across Source, Kustomize, Helm, Notification, and Image.
Both sources are the release install.yaml — a multi-doc bundle of controllers, RBAC, and CRDs. The parser keeps only the CRD documents, and a kinds allowlist on each CRD_SOURCES entry narrows those to the supported set (the bundle also carries the experimental ExternalArtifact and ArtifactGenerator, left out for now).
| Type | apiVersion / kind |
|---|---|
GitRepository | source.toolkit.fluxcd.io/v1 |
OCIRepository | source.toolkit.fluxcd.io/v1 |
HelmRepository | source.toolkit.fluxcd.io/v1 |
HelmChart | source.toolkit.fluxcd.io/v1 |
Bucket | source.toolkit.fluxcd.io/v1 |
Kustomization | kustomize.toolkit.fluxcd.io/v1 |
HelmRelease | helm.toolkit.fluxcd.io/v2 |
Provider | notification.toolkit.fluxcd.io/v1beta3 |
Alert | notification.toolkit.fluxcd.io/v1beta3 |
Receiver | notification.toolkit.fluxcd.io/v1 |
ImagePolicy | image.toolkit.fluxcd.io/v1 |
ImageRepository | image.toolkit.fluxcd.io/v1 |
ImageUpdateAutomation | image.toolkit.fluxcd.io/v1 |
FluxInstance | fluxcd.controlplane.io/v1 |
FluxReport | fluxcd.controlplane.io/v1 |
ResourceSet | fluxcd.controlplane.io/v1 |
ResourceSetInputProvider | fluxcd.controlplane.io/v1 |
Install the toolkit and, optionally, the operator:
# GitOps Toolkit controllers (source, kustomize, helm, notification, image)kubectl apply -f https://github.com/fluxcd/flux2/releases/download/v2.9.1/install.yaml
# Flux Operator (manages a Flux instance declaratively)kubectl apply -f https://github.com/controlplaneio-fluxcd/flux-operator/releases/download/v0.54.1/install.yamlimport { GitRepository, Kustomization } from "@intentius/chant-lexicon-k8s";
export const podinfo = new GitRepository({ metadata: { name: "podinfo", namespace: "flux-system" }, spec: { interval: "1m", url: "https://github.com/stefanprodan/podinfo", ref: { branch: "master" }, },});
export const apps = new Kustomization({ metadata: { name: "apps", namespace: "flux-system" }, spec: { interval: "10m", path: "./kustomize", prune: true, sourceRef: { kind: "GitRepository", name: "podinfo" }, },});The FluxGitSource and FluxAppFor composites collapse the GitRepository + Kustomization pair above into two calls with estate-tested defaults, and the FLUX001–003 lint rules validate the source pin, sourceRef, and dependsOn edges.
Read-only status
Section titled “Read-only status”Generated CRD classes expose a read-only status accessor alongside name, namespace, and uid. It carries the resource’s server-owned runtime state — never part of the writable constructor.
import { Certificate } from "@intentius/chant-lexicon-k8s";
const cert = new Certificate({ metadata: { name: "web-tls", namespace: "prod" }, spec: { secretName: "web-tls", issuerRef: { name: "letsencrypt" } },});
// Read-only — populated at apply time, typed from the CRD's status schema.cert.status; // Certificate_Status — notAfter, notBefore, renewalTime, revision, conditionsThe per-field status shape is generated from the CRD’s openAPIV3Schema. Scalar leaves (notAfter, revision, a RayCluster’s head.serviceIP) are typed; deeply nested or x-kubernetes-preserve-unknown-fields status degrades to an opaque record. CRDs without a status schema (config-only kinds like a Prometheus ServiceMonitor) get no status accessor.
Adding your own CRD
Section titled “Adding your own CRD”Append an entry to CRD_SOURCES in lexicons/k8s/src/crd/crd-sources.ts and re-run codegen:
export const CRD_SOURCES: CRDSource[] = [ // ...existing entries { type: "url", url: "https://raw.githubusercontent.com/acme/operator/v1.0.0/config/crd/bases/acme.io_widgets.yaml" },];cd lexicons/k8s && npm run generateGuidelines:
- Pin the version in the URL (a
vX.Y.Ztag or release asset), nevermain. Codegen is deterministic only if the source is. - One URL per CRD, or a single multi-doc bundle URL (the parser uses
loadAll, as with cert-manager). - The group’s first segment becomes the namespace. Add a
GROUP_NAMESPACE_OVERRIDESentry incrd/parser.tsif the default mapping reads poorly. - Document the produced classes and the operator install command in a comment block next to the entry — match the existing entries.
A kind with no generated class: k8sManifest
Section titled “A kind with no generated class: k8sManifest”k8sManifest declares an object from its manifest, for a kind the lexicon ships no class for and you do not want to generate one for:
import { k8sManifest } from "@intentius/chant-lexicon-k8s";
export const widget = k8sManifest({ apiVersion: "acme.io/v1", kind: "Widget", metadata: { name: "demo", namespace: "web" }, spec: { size: 3 },});The props are the manifest: the serializer emits the document as written, plus the default-label and ownership merge every discovered resource gets. apiVersion and kind are required — they are what makes the object addressable — and they resolve the entity type through the same group rule a generated class uses, so acme.io/v1 Widget is K8s::Acme::Widget either way and lifecycle diff --live reads it through the same operation surface.
What you give up is the whole point of a generated class: no typed constructor, no spec validation at lint time, no LSP hover. Prefer generating the CRD. Two callers use this deliberately — a kustomize build root, whose output is already finished documents, and chant carve emit adopting a Terraform kubernetes_manifest, whose kind is only known once the body is read.