Adding Operators
How to add new Kubernetes operator integrations to r8s — CRD-driven, 1:1 with upstream, no hand-written type mappings.
The Principle
r8s generates TypeScript types and components directly from upstream CRD OpenAPI v3 schemas. This means 100% fidelity to the operator's API — no renamed fields, no collapsed arrays, no invented defaults. If the CRD has a field, r8s has it typed. If the CRD doesn't have a field, r8s doesn't either.
Two layers, one boundary
- @r8s/crds — generated, 1:1 with CRDs. Types and components named exactly as the upstream Kind. Never hand-edited.
- @r8s/recipes — the only place for abstraction. Composes generated components into higher-level patterns (App, Platform, Endpoint).
Package Structure
All operator integrations live in a single package: @r8s/crds. CRD YAML files are vendored at pinned versions. The generator reads them and produces TypeScript files that are committed to the repo.
packages/crds/
├── crds/ # Vendored CRD YAML (pinnade upstream versioner)
│ ├── cnpg-cluster.yaml
│ ├── certmanager-certificate.yaml
│ └── ...
├── operators.yaml # All operator declarations (single source of truth)
├── scripts/
│ └── generate.ts # CRD → TypeScript generator
└── src/
├── index.ts # Re-exports generated modules
└── generated/ # OUTPUT — never hand-edited
├── postgresql.ts # Cluster, Pooler, ScheduledBackup + 244 interfaces
├── cert-manager.ts
├── operators.ts # operators['cnpg']('1.27.0') etc.
└── ...Step 1 — Vendor the CRD
Download the CRD YAML from the operator's release artifacts. Pin to a specific version — the file is committed to the repo so renders are reproducible offline.
# 1. Download the CRD YAML from the operator's release
curl -sfL "https://raw.githubusercontent.com/example/my-operator/v1.4.2/config/crd/bases/example.com_widgets.yaml" \
-o packages/crds/crds/my-operator-widget.yaml
# 2. Add the operator to operators.yaml (see above)
# 3. Regenerate
npm run generate -w @r8s/crdsStep 2 — Declare the Operator
Add an entry to operators.yaml. This is the single source of truth for how operators are installed — Helm chart, raw manifest, or OLM. The crds list is what users see when rendering with --include-operators.
# operators.yaml — add an entry per operator
- name: my-operator
description: What the operator does
source:
type: helm # helm | manifest | olm
chart: my-operator
repository: https://charts.example.com/
version: "1.4.2"
namespace: my-operator-system
crds:
- widgets.example.com
- gadgets.example.comVersion placeholders
URLs support {version} and {minor} placeholders (e.g.release-{minor} expands to release-1.27 for version 1.27.0). This lets you bump a version in one place.
Step 3 — Regenerate
The generator extracts the OpenAPI v3 schema from each CRD, produces TypeScript interfaces for every nested object, and a component function per Kind. The output goes to src/generated/ — one file per API group.
// src/generated/example.ts — GENERATED, do not edit
import type { ObjectMeta } from '@r8s/k8s-types'
export interface Widget {
apiVersion: 'example.com/v1'
kind: 'Widget'
metadata: ObjectMeta
spec: WidgetSpec
}
export interface WidgetProps {
metadata: ObjectMeta
spec: WidgetSpec
}
export function WidgetComponent(props: WidgetProps): Widget {
return {
apiVersion: 'example.com/v1',
kind: 'Widget',
metadata: props.metadata,
spec: props.spec,
}
}The component is intentionally trivial — it sets apiVersion and kind, passes metadata and spec through. No logic, no defaults, no place for drift to creep in.
Using Generated Components
Import per API group file to avoid name collisions (generic nested interfaces like LabelSelector exist in multiple groups):
import { WidgetComponent } from '@r8s/crds/example'
import { operators } from '@r8s/crds'
// The component is a 1:1 mapping of the CRD — no simplification, no renamed fields
const widget = WidgetComponent({
metadata: { name: 'my-widget', namespace: 'default' },
spec: {
replicas: 3,
// ...every field from the upstream CRD schema is typed
},
})
// Operator declarations come from operators.yaml
const op = operators['my-operator']('1.4.2')Adding a Recipe
Recipes are the only place for abstraction. A recipe composes generated components and raw resources into a higher-level pattern. Add one file per recipe in packages/recipes/src/, export it from index.ts, and tag the JSDoc with @title and @category.
// packages/recipes/src/my-recipe.tsx
import { jsx, Fragment, declareOperator } from '@r8s/core'
import { WidgetComponent } from '@r8s/crds/example'
import { operators } from '@r8s/crds'
export interface MyRecipeProps {
name: string
replicas?: number
}
/**
* @title My Recipe
* @category Data & Analytics
*
* A higher-level pattern composing CRD components.
*
* @example
* <MyRecipe name="cluster" replicas={3} />
*/
export function MyRecipe(props: MyRecipeProps) {
const { name, replicas = 1 } = props
return (
<>
{declareOperator(operators['my-operator']())}
{jsx(WidgetComponent, {
metadata: { name, namespace: 'default' },
spec: { replicas },
})}
</>
)
}
// Then export from packages/recipes/src/index.ts:
// export { MyRecipe } from './my-recipe'What We Look For in Review
A new operator integration is accepted when all of these hold. Reviewers check this list — save everyone a round-trip by verifying it yourself first.
Ready to contribute?
Vendor the CRD, add the operator entry, regenerate, and open a PR — we review within 24 hours on weekdays. Questions? Open an issue on GitHub.