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/crds

Step 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.com

Version 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.

CRD YAML vendored at a pinned upstream version
operators.yaml entry complete: name, source, version, namespace, crds
Generator runs clean: npm run generate -w @r8s/crds produces no warnings
Generated types compile: npx tsc -b passes
Component names match upstream Kinds exactly (no renames)
No hand-written type mappings — everything comes from the CRD schema
CI green: build, tests, format check

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.