cue-kind-definition
CUE Kind Definition
Kinds are the schema definitions that drive the entire grafana-app-sdk code generation pipeline. Each kind describes a Kubernetes-style resource type: its name, versions, and per-version schema. All Go types, TypeScript types, API clients, CRD manifests, and the AppManifest are generated from these CUE files.
Adding a Kind
Use the CLI to scaffold a kind before editing:
grafana-app-sdk project kind add <KindName> --overwrite
This creates a .cue file with scaffolding, field comments, and example values. Read the generated comments carefully — they explain every field's purpose.
Always use
--overwritewhen re-running to regenerate scaffolding without losing manual additions.
Kind File Structure
grafana-app-sdk project kind add creates files directly in kinds/ — the default layout is flat, all in package kinds:
kinds/
├── manifest.cue # App manifest + version list declarations
├── mykind.cue # Common (cross-version) kind metadata
└── mykind_v1alpha1.cue # v1alpha1 schema + codegen config
For multi-version kinds the additional version files sit alongside:
kinds/
├── manifest.cue
├── mykind.cue
├── mykind_v1alpha1.cue
└── mykind_v1.cue
For larger, more complex kind definitions users may choose to organise kinds into per-kind and per-version subdirectories, each with their own package. The default CLI output uses the flat layout above.
CUE Kind Anatomy
A complete kind definition has three layers:
1. Common kind metadata (shared across versions)
// kinds/mykind.cue
package kinds
myKind: {
kind: "MyKind" // Required: the kind name (PascalCase)
// other cross-version fields (scope, pluralName, validation, mutation, conversion, etc.)
// See references/kind-layout.md for the full field reference
}
2. Per-version schema (one file per version)
Each version joins the common metadata with its own schema via CUE's & operator:
// kinds/mykind_v1alpha1.cue
package kinds
myKindv1alpha1: myKind & {
// Version-specific schema
schema: {
// spec: desired state — set by users/clients, never by the operator
spec: {
title: string
description: string | *"" // optional with default
count: int & >=0
enabled: bool | *true
}
// status: observed state — written only by the operator/reconciler,
// never by users. Mirrors Kubernetes spec/status conventions.
status: {
lastObservedGeneration: int | *0
state: string | *""
message: string | *""
}
}
// Code generation config
codegen: {
ts: { enabled: true } // generate TypeScript types
go: { enabled: true } // generate Go types and client
}
}
3. App manifest (version registration)
Since all files share package kinds, version objects are referenced directly — no imports needed in the flat layout:
// kinds/manifest.cue
package kinds
App: {
appName: "my-app"
versions: {
"v1alpha1": {
schema: myKindv1alpha1
}
}
}
spec vs status
This distinction follows Kubernetes conventions exactly:
spec — desired state. Written by users and clients. The operator reads spec and works to make the world match it. Admission handlers validate and mutate spec. Never write to spec from a reconciler.
status — observed state. Written only by the operator/reconciler after it has done work. Users and clients should treat status as read-only. Admission handlers must not modify status.
Typical status fields:
status: {
// Generation of the spec that was last successfully reconciled.
// Set to metadata.generation after a successful reconcile loop.
lastObservedGeneration: int | *0
// Human-readable summary of current state
state: string | *"" // e.g. "Ready", "Provisioning", "Error"
message: string | *"" // detail, especially on error
// References to objects created by the reconciler.
// e.g. the name of a ConfigMap or Deployment the reconciler provisioned.
provisionedConfigMap: string | *""
provisionedServiceAccount: string | *""
}
Fields that belong in status, not spec:
- Anything the operator computes or creates (IDs, names, URLs of provisioned resources)
lastObservedGeneration/observedGenerationconditions(Kubernetes-style condition arrays)- Current health or lifecycle state (
"Ready","Degraded", etc.) - Timestamps of when the operator last acted
Fields that belong in spec, not status:
- Everything the user configures as desired state
- References to existing resources the user wants the app to interact with (the operator looks these up, it doesn't create them)
Type Definitions with #
CUE supports named type definitions using the # prefix inside a schema block. Each #Definition generates a named Go struct and TypeScript interface alongside the kind's Spec type.
schema: {
#Threshold: {
value: float & >=0
severity: "info" | "warning" | "critical"
message: string | *""
}
#ResourceRef: {
name: string & != ""
namespace: string | *"default"
}
spec: {
title: string & != ""
alertThreshold: #Threshold
thresholds: [...#Threshold] // list of a defined type
targetRef?: #ResourceRef // optional
}
}
# definitions are scoped to the schema block they are declared in.
Prefer # definitions when:
- A struct is used in more than one field
- A struct is large or complex enough that inlining hurts readability
- A struct appears in a list (
[...#MyType])
Inline structs are fine when:
- The struct is small and simple (2-3 fields) or shallow
- It is used in only one place and unlikely to be reused
Maps ({[string]: string}) and lists of scalars ([...string]) are always fine inline.
Schema Field Types
CUE is a superset of JSON. Commonly used types and constraints:
// Basic types
myString: string
myInt: int
myFloat: float
myBool: bool
myBytes: bytes
// Optional with default
name: string | *"default-value"
// Constraints (using & to intersect)
port: int & >=1 & <=65535
label: string & =~"^[a-z][a-z0-9-]*$" // regex constraint
// Enums (disjunctions)
status: "pending" | "active" | "archived"
// Maps (always fine inline)
labels: {[string]: string}
attrs: {[string]: _}
// Lists of scalars (fine inline)
tags: [...string]
// Optional field
description?: string
Custom Routes in CUE
Routes can be defined at two levels. Both require corresponding Go handlers registered in app.go.
Kind-level routes
MyKind: {
kind: "MyKind"
schema: { ... }
routes: {
"/actions/process": {
"POST": {
name: "processMyKind" // unique within version; must start with a k8s verb
request: {
body: {
reason: string
}
}
response: {
jobId: string
status: string
}
}
}
}
}
Version-level routes
versions: {
"v1alpha1": {
routes: {
namespaced: {
"/summary": {
"GET": {
name: "getNamespacedSummary"
response: { count: int }
}
}
}
cluster: {
"/health": {
"GET": {
name: "getHealth"
response: { status: string }
}
}
}
}
}
}
After adding routes, run grafana-app-sdk generate — routes are included in the AppManifest and ValidateManifest will fail if a handler is missing.
Version Compatibility Rules
When a kind has multiple versions, fields declared in the common metadata object must match across all versions. Schema fields (inside schema.spec) can differ per version, but:
- The
kindfield must be identical in every version - Breaking changes (removing fields, changing types, adding required fields) must be introduced via a new version — never by modifying a stable version (
v1,v2) - Use
statusfor server-managed fields; never put mutable server state inspec
Codegen Configuration
Control what gets generated per kind per version:
codegen: {
ts: { enabled: true | false } // TypeScript types
go: { enabled: true | false } // Go types + client
}
Disabling go for frontend-only apps avoids generating unused Go code. Disabling ts for backend-only resources reduces TypeScript bundle size. Both default to true when omitted.
After Editing Kinds
Always run generate after any change to .cue files:
grafana-app-sdk generate
The generated files in pkg/generated/ must never be edited manually — they are overwritten on every generate run.