Introduction
Typed configuration for Kubernetes.
Cleat is a configuration language whose values are JSON and whose types come
from the real Kubernetes API and your CRDs. Mistakes are caught while you
write, at the exact field that’s wrong, and the output is plain YAML for
Argo CD, Flux, or kubectl apply.
Cleat is under active development. The language, the CLI and the editor and Argo CD integrations can change in incompatible ways, and it hasn’t been used in production yet. “Cleat” is a working name.
A taste
import * as k8s from "../schemas/kubernetes-1.37.json"
input env: "dev" | "prod"
input image: Str
input replicas: Int where self >= 1 = 2
export k8s.Deployment {
metadata.name: "api",
spec: {
replicas: if env == "prod" then max(replicas, 3) else replicas,
selector.matchLabels.app: "api",
template: {
metadata.labels.app: "api",
spec.containers: [{ name: "api", image, ports: [{ containerPort: 8080 }] }],
},
},
}
apiVersion and kind fill themselves in. Misspell replicas, or write
imagePullPolicy: "Allways", and the mistake is reported before anything
reaches a cluster:
$ cleat check examples/quickstart.cleat
quickstart.cleat:13:5: error: unknown field `replcias` in `k8s.apps.v1.DeploymentSpec`; did you mean `replicas`?
quickstart.cleat:17:64: error: type mismatch: expected `"Always" | "IfNotPresent" | "Never"`, found `"Allways"`
Bad inputs are rejected before evaluation, all of them at once:
$ cleat export examples/quickstart.cleat --set env=production --set image=api:1 --set replicas=0
quickstart.cleat:6:1: error: input `env`: expected `"dev" | "prod"`, got "production" (string)
quickstart.cleat:8:1: error: input `replicas`: 0 does not satisfy `self >= 1`
Why Cleat
- Types from the real API. Import the Kubernetes OpenAPI spec and your CRD files; every resource, field, required field and enum is checked, and field documentation shows on hover.
- Typed inputs instead of
values.yaml. Inputs declare their types, defaults and constraints, and bad values are rejected up front. - Overlays that can’t silently miss. Patches are checked against the types; a misspelled field or a selector that matches nothing is an error.
- Dependencies between components become Argo CD sync waves or Flux
dependsOn. - Small and familiar. TypeScript-style structural types, ES-style imports, and no recursion or I/O, so evaluation always terminates and depends only on the repository.
- Made for editors. Completion with Kubernetes docs, hover, go to definition, errors as you type, and a live YAML preview.
Compared with
| Helm | Kustomize | Cleat | |
|---|---|---|---|
| How you write config | Go templates over YAML text | YAML plus patches | A typed language that outputs YAML |
| Checks against the Kubernetes API | After rendering | After rendering | While writing, with the field and a fix |
| Inputs | values.yaml (optional JSON Schema) | none | Typed inputs with defaults and constraints |
| Variations per environment | values files | overlays | modules and typed with patches |
| Ordering between resources | hooks and weights | by kind only | after → sync waves / dependsOn |
Getting started
The quickest way to try Cleat is the playground, which runs
the real checker in your browser. To use it on your own files, build the
cleat command.
Install
Cleat is built from source for now. rustup installs the
Rust version the repository pins in rust-toolchain.toml.
git clone https://github.com/xorps/cleat-lang && cd cleat-lang
cargo install --path crates/cleat # installs the `cleat` binary
schemas/fetch.sh 1.37 # the Kubernetes OpenAPI spec, checksum-verified
Your first file
Save this as hello.cleat:
input name: Str
input replicas: Int where self >= 1 = 1
export {
apiVersion: "v1",
kind: "ConfigMap",
metadata.name: "hello-\(name)",
data.replicas: "\(replicas)",
}
Check it, then export it with inputs:
$ cleat check hello.cleat
0 error(s), 0 warning(s); refinements: 1 proved statically, 0 checked at runtime
$ cleat export hello.cleat --set name=world
apiVersion: v1
kind: ConfigMap
metadata:
name: hello-world
data:
replicas: "1"
--set decodes each value by the input’s declared type, so
--set replicas=3 is an integer and --set replicas=0 is rejected by the
where constraint. Inputs can also come from a JSON or YAML file:
--input values.yaml.
Against the Kubernetes API
Import the schema, and resources are checked field by field:
import * as k8s from "./schemas/kubernetes-1.37.json"
export k8s.ConfigMap {
metadata.name: "hello",
data.greeting: "hi",
}
See Kubernetes and CRD types for CRDs and for narrowing types to your own policies.
Editor
For VS Code, build and install the extension (no npm needed):
editors/vscode/package.sh && code --install-extension editors/vscode/cleat.vsix
See Editors for what it does and for other editors.
Next
- A tour of the language
- The examples in the repository: the quickstart, a web app with its database, and a shared base with a production overlay.
A tour of the language
Every value in Cleat is a JSON value: null, booleans, numbers, strings,
lists and objects. Types describe those values, structurally, the way
TypeScript does. You can run any of this in the playground.
Types
type Env = "dev" | "staging" | "prod" // a union of literal types
type Port = Int where 1 <= self && self <= 65535 // a refinement
type Container = {
name: Str, // required
image: Str,
port?: Port, // optional
pullPolicy: "Always" | "IfNotPresent" = "IfNotPresent", // with a default
}
type Labels = { [Str]: Str } // a map
type Endpoint = (Str, Port) // a tuple
The base types are Bool, Int, Float, Str, null, Never (no
values) and Json (any value). Records are exact unless they end with ..,
which allows other fields. Keys may be quoted:
"app.kubernetes.io/name": Str.
Inputs
Inputs are the values supplied at export time, from --set, an inputs file
or Argo CD parameters. Each one has a type, and optionally a default:
input env: Env
input image: Str
input replicas: Int where self >= 1 = 2
input debug: Bool = false
All inputs are decoded and checked before evaluation starts, and every bad input is reported at once.
Constants and functions
let app = "api"
fn labels(name: Str, tier: Str = "backend") -> Labels = {
"app.kubernetes.io/name": name,
tier, // shorthand for `tier: tier`
}
fn url(e: Endpoint) -> Str = "http://\(e.0):\(e.1)"
A function’s body is one expression. Calls take positional or named
arguments: labels(app, tier: "web"). A function can only call functions
declared above it, so there is no recursion and evaluation always ends.
Choices
fn size(e: Env) -> Int = match e {
"prod" => 3,
"staging" => 2,
"dev" => 1,
}
match over a union of literals is checked for exhaustiveness: leave out
"staging" and it’s an error. if c then a else b is an expression.
a ?? b gives b when a is null, and x?.f reads a field of a value
that may be null. Comparing with null narrows the type, as in
TypeScript.
Objects and lists
let main: Container = { name: app, image, port: 8080 }
let sidecar: Container = { name: "log", image: "busybox:1.37" }
let containers = [main, if debug then sidecar]
let ports = [for c in containers => if c.port != null then c.port]
let names = { for c in containers => { [c.name]: c.image } }
let x: T = echeckseagainstTand fills in defaults (herepullPolicy).ifwithoutelseinside a list or object adds the element only when the condition holds.for x in xs => eis a comprehension. In an object, it computes keys.- Dotted keys nest objects:
metadata.labels.app: "api"ismetadata: { labels: { app: "api" } }. ...espreads a list or an object.
Strings
Interpolation is \(expr), so ${VAR} in shell commands is left alone.
Multi-line strings start after """ and end at a """ on its own line,
whose indentation is removed from every line:
let conf = """
server {
listen \(main.port ?? 80);
}
"""
Output
Each export emits documents (a list emits one document per element):
export {
name: app,
labels: labels(app, tier: "web"),
replicas: if env == "prod" then max(replicas, size(env)) else replicas,
containers,
conf,
}
$ cleat export tour.cleat --set env=prod --set image=ghcr.io/acme/api:1.4.2
name: api
labels:
app.kubernetes.io/name: api
tier: web
replicas: 3
containers:
- name: api
image: ghcr.io/acme/api:1.4.2
port: 8080
pullPolicy: IfNotPresent
conf: |-
server {
listen 8080;
}
Imports
import * as k8s from "../schemas/kubernetes-1.37.json" // types from a schema
import * as base from "./base.cleat" // a Cleat module
import { Deployment, Service } from "../schemas/kubernetes-1.37.json"
import defaults from "./defaults.yaml" with { type: "yaml" } // data, typed from its contents
import nginxConf from "./nginx.conf" with { type: "text" }
Import paths are string literals, all imports are resolved before
evaluation, and they can’t leave the project root (the nearest directory
with .git, or --root).
Patches
e with { ... } returns a copy of e with changes, checked against the
value’s type:
export base.app("api", image) with {
[kind: "Deployment"].spec.replicas: 3,
[kind: "Deployment"].metadata.labels +: { tier: "prod" },
-[kind: "Deployment"].spec.strategy,
}
See Bases and environment overlays.
Built-in functions
len, keys, values, str, upper, lower, join, split,
contains, starts_with, ends_with, replace, matches (regex), min,
max, sort, to_json, to_yaml, b64encode and error. Hover over one
in an editor to see its signature.
Kubernetes and CRD types
Cleat reads the Kubernetes OpenAPI spec and CRD files directly and turns them into Cleat types. Nothing is generated into your repository.
The Kubernetes API
schemas/fetch.sh 1.37 # downloads the 1.37 spec and verifies its checksum
import * as k8s from "../schemas/kubernetes-1.37.json"
export k8s.Service {
metadata.name: "api",
spec: {
selector.app: "api",
ports: [{ port: 80, targetPort: 8080 }],
},
}
Each resource has a short name (k8s.Deployment, k8s.Service) and its
full name (k8s.apps.v1.Deployment). When a kind exists in several API
versions, the short name is the most stable one. Resource types fill in
their own apiVersion and kind.
What’s checked:
- every field name, with a “did you mean” suggestion for typos;
- required fields;
- value types, including int-or-string fields (
targetPort: 8080or"http") and quantities (cpu: "500m"orcpu: 0.5); - enums: those in the spec, plus about 40 that the spec leaves as plain
strings, like
Service.spec.type,imagePullPolicyandrestartPolicy; - patterns from the schema (for example on names), as regex refinements.
Field documentation from the spec appears on hover and in completion.
cleat gen schemas/kubernetes-1.37.json prints all the generated types.
Named imports bring individual types into scope:
import { Deployment, Service } from "../schemas/kubernetes-1.37.json"
CRDs
Import CRD manifests, as YAML or JSON, the same way:
import * as cm from "./crds/cert-manager.crds.yaml"
export cm.Certificate {
metadata.name: "web-tls",
spec: {
secretName: "web-tls",
dnsNames: ["example.com"],
issuerRef: { name: "letsencrypt", kind: "ClusterIssuer" },
},
}
A CRD’s storage version gets the short name; other versions are available by their full names.
Your own policies: with and refine
Schemas describe what an API accepts, which is usually looser than your
project’s policy. T with { ... } makes a narrower copy of a type:
type SmallDeployment = k8s.Deployment with {
spec.replicas where self <= 10, // add a predicate
spec.template.spec.containers[].image where contains(self, ":"), // `[]`: each element
spec.strategy.type: "RollingUpdate" | "Recreate", // replace the type
spec.revisionHistoryLimit: Int = 3, // ... and set a default
-status, // remove an optional field
}
refine T with { ... } narrows a type in place, so every use of it in the
file, including inside other imported types, sees the narrower version:
// No NodePort or ExternalName Services in this project.
refine k8s.ServiceSpec with { type: "ClusterIP" | "LoadBalancer" }
Both only narrow: a replacement type must be a subtype of the original, only optional fields can be removed, and a field stays required or optional as it was. So anything that satisfies your refined type is still valid Kubernetes.
Bases and environment overlays
Cleat replaces Kustomize bases and overlays with modules and typed patches. A module holds the shared pieces; each environment imports it and patches the result.
A shared base
A module is a .cleat file that defines types, functions and constants. It
can import schemas and refine types, but it can’t declare inputs or
export anything: it takes values as function parameters instead.
// examples/overlays/base.cleat
import * as k8s from "../../schemas/kubernetes-1.37.json"
// Project policy: no NodePort or ExternalName Services.
refine k8s.ServiceSpec with { type: "ClusterIP" | "LoadBalancer" }
type DnsLabel = Str where len(self) <= 63 && matches(self, "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$")
type Port = Int where 1 <= self && self <= 65535
fn labels(app: Str) -> { [Str]: Str } = {
"app.kubernetes.io/name": app,
"app.kubernetes.io/part-of": "shop",
}
/// A Deployment and a Service for one HTTP app.
fn app(name: DnsLabel, image: Str, port: Port = 8080) -> [k8s.Deployment | k8s.Service] = [
k8s.Deployment {
metadata: { name, labels: labels(name) },
spec: {
replicas: 1,
selector.matchLabels: labels(name),
template: {
metadata.labels: labels(name),
spec.containers: [{ name, image, ports: [{ name: "http", containerPort: port }] }],
},
},
},
k8s.Service {
metadata: { name, labels: labels(name) },
spec: { selector: labels(name), ports: [{ name: "http", port: 80, targetPort: "http" }] },
},
]
An overlay
// examples/overlays/prod.cleat
import * as base from "./base.cleat"
input image: Str
export api = base.app("api", image) with {
[kind: "Deployment"].spec.replicas: 3,
[kind: "Deployment"].metadata.labels +: { tier: "prod" },
[kind: "Deployment"].spec.template.spec.containers[name: "api"].resources.limits: { cpu: "1", memory: "512Mi" },
[kind: "Deployment"].spec.template.spec.containers[name: "api"].env +: [{ name: "LOG_LEVEL", value: "warn" }],
[kind: "Service"].spec.type: "LoadBalancer",
}
| Entry | Effect |
|---|---|
path: v | Set the value at path, creating missing objects on the way. |
path +: v | Merge: lists append; objects follow JSON Merge Patch (nested objects merge, null removes a key). |
-path | Remove a field, or the selected list elements. |
| Path segment | Selects |
|---|---|
.f | a field |
[] | every list element or map value |
[2] | one list element |
[name: "api", ...] | the list elements whose fields equal these values |
Mistakes are errors, not no-ops
Patches into declared types (like k8s.Deployment) are checked against
them, and a selector that matches nothing is an error. The base’s refine
applies to the overlay too:
import * as base from "./base.cleat"
input image: Str
export api = base.app("api", image) with {
[kind: "Deployment"].spec.replcias: 3,
[kind: "Service"].spec.type: "NodePort",
}
$ cleat check prod.cleat
prod.cleat:4:3: error: `[kind: "Deployment"].spec.replcias`: `base.k8s.apps.v1.DeploymentSpec` has no field `replcias`; did you mean `replicas`?
prod.cleat:5:32: error: type mismatch: expected `"ClusterIP" | "LoadBalancer"`, found `"NodePort"`
2 error(s), 0 warning(s); refinements: 1 proved statically, 0 checked at runtime
In anonymous objects (no declared type), a patch updates the type
structurally instead: { n: 1 } with { n: "one" } has type { n: "one" }.
Components and dependencies
A named export is a component. after lists the components that must be
healthy before it is applied:
export config = [settings]
export database = [postgres]
export app after database, config = [api]
Unknown names and cycles are compile errors:
$ cleat check cycle.cleat
cycle.cleat:1:1: error: dependency cycle between components: a -> b -> a
Unnamed exports belong to a component called default, with no
dependencies.
Waves
A component’s wave is 0 if it has no dependencies, otherwise one more than
the largest wave among its dependencies. Above, config and database are
wave 0 and app is wave 1.
How dependencies reach the cluster
The output target decides:
| Target | Output |
|---|---|
plain (default) | One YAML stream, in wave order. |
argocd | The same, with the argocd.argoproj.io/sync-wave annotation set on every document (an existing one is kept). See Argo CD. |
flux | A directory per component and a Flux Kustomization for each, with dependsOn. See Flux. |
$ cleat export app.cleat --target argocd
apiVersion: v1
kind: ConfigMap
metadata:
name: settings
annotations:
argocd.argoproj.io/sync-wave: "0"
...
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
annotations:
argocd.argoproj.io/sync-wave: "1"
Argo CD
Cleat runs as an Argo CD
config management plugin:
a sidecar container next to argocd-repo-server. The sidecar image is about
6 MB: a statically linked cleat and the plugin config on distroless, with
no shell.
Install the sidecar
Build and push the image (multi-architecture builds need no emulation):
docker buildx build -f deploy/argocd/Dockerfile --platform linux/amd64,linux/arm64 \
-t registry.example.com/cleat-cmp:dev --push .
Then add the sidecar to the repo server. Edit the image in
deploy/argocd/repo-server-patch.yaml to the one you pushed, and apply it:
kubectl -n argocd patch deployment argocd-repo-server --patch-file deploy/argocd/repo-server-patch.yaml
The image carries its plugin config (deploy/argocd/plugin.yaml), so nothing
else needs to be installed. The plugin is called cleat-v1, and it also
matches, without being named, any application whose directory has a
main.cleat.
An Application
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata: { name: shop, namespace: argocd }
spec:
project: default
source:
repoURL: https://github.com/acme/deploy.git
targetRevision: main
path: apps/shop
plugin:
name: cleat-v1
parameters:
- { name: env, string: prod }
- { name: image, string: ghcr.io/acme/api:1.4.2 }
destination: { server: https://kubernetes.default.svc, namespace: shop }
Plugin env (spec.source.plugin.env) | Meaning |
|---|---|
CLEAT_FILE | The entry file in the application’s directory (default main.cleat). |
CLEAT_TARGET | argocd (default, adds sync waves) or plain. |
What you get
- Parameters in the UI. Each
inputin the entry file is announced to Argo CD as a parameter, with its type as the tooltip and its default filled in. - Typed parameters. String parameters are decoded by the input’s type,
as with
--set; array and map parameters become lists and maps. - Errors in the Application’s status. An invalid parameter or a type
error fails the manifest generation with Cleat’s message, for example
input `env`: expected `Env` (`"dev" | "prod"`), got "production" (string). - Sync waves from
afterdependencies (see Components). - Imports stay in the repository. The project root is the repository checkout, so an application can import shared modules and schemas from elsewhere in the repository, and nothing outside it.
Testing it
deploy/argocd/e2e/run.sh creates a throwaway kind cluster, installs a
pinned Argo CD, builds the sidecar, and checks all of the above against a
real Argo CD. deploy/argocd/e2e/run.sh cleanup deletes the cluster. In
the repository’s CI it runs weekly, on demand, and on pull requests labeled
e2e.
Flux
Flux applies plain manifests from Git, so with Flux you run cleat export
in CI (or before committing) and commit the output.
cleat export app.cleat --target flux --out deploy --flux-source shop --flux-path deploy
This writes a directory per component and one file of Flux
Kustomizations:
deploy/app/resources.yaml
deploy/config/resources.yaml
deploy/database/resources.yaml
deploy/flux-kustomizations.yaml
Each Kustomization points at its component’s directory, depends on the
components in its after list, and has wait: true, so dependents wait
until their dependencies are healthy rather than just applied:
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: app
namespace: flux-system
spec:
interval: 10m
path: ./deploy/app
prune: true
wait: true
sourceRef:
kind: GitRepository
name: shop
dependsOn:
- name: database
- name: config
| Option | Meaning |
|---|---|
--out <dir> | Where to write (required). |
--flux-source <name> | The GitRepository the Kustomizations read from (required). |
--flux-path <path> | The repository path of --out (default .). |
--flux-namespace <ns> | Namespace of the Kustomizations (default flux-system). |
--flux-prefix <prefix> | Prefix for the Kustomization names. |
Apply flux-kustomizations.yaml once (or include it from your cluster’s
Flux configuration), and Flux picks up changes to the component
directories from then on.
Editors
cleat lsp is a language server on stdio, so any editor with LSP support can
use it. The repository includes a VS Code extension that needs no npm.
VS Code
editors/vscode/package.sh && code --install-extension editors/vscode/cleat.vsix
Open a .cleat file and you get:
- Errors as you type, including in files that import the one you’re editing, and unsaved changes to imported files.
- Completion of fields (with Kubernetes documentation), values of enum and literal types, variables, functions, modules and types. Required fields not yet set come first.
- Hover with types and documentation, and go to definition, including into imported modules and generated schema types.
- A live preview of the exported YAML: run Cleat: Show Output (the preview icon in the editor title bar), and it updates as you type.
- A resource map: Cleat: Show Resource Map (the hierarchy icon in
the editor title bar) opens the
cleat viewdashboard beside the file: the map, tables, checks between resources and each resource’s details, updating as you type. Selecting a resource jumps to where it’s defined, such as theletthat builds it.
Inputs while editing
Put an inputs file next to the entry file, app.inputs.json or
app.inputs.yaml beside app.cleat:
{ "env": "dev", "image": "ghcr.io/acme/api:1.4.2" }
The preview exports with it, and the language server also evaluates with it,
so errors that only show up with real inputs (a runtime refinement check, an
error(...) call) appear in the editor at the call site.
Settings
| Setting | Meaning |
|---|---|
cleat.serverPath | The cleat binary. Default: target/debug/cleat or target/release/cleat in the workspace, else cleat on PATH. |
cleat.compareWith | A git revision (e.g. main) to compare with in the resource map: new, changed and removed resources are marked, as with cleat view --diff. |
cleat.exportArgs | Extra cleat export arguments for the preview, e.g. ["--set", "env=dev", "--target", "argocd"]. |
Other editors
Configure the editor to start cleat lsp for .cleat files. The server
supports diagnostics, hover, completion and go to definition, with full
document sync.
Command line
usage:
cleat check <file.cleat> [--vcs]
cleat export <file.cleat> [options]
cleat view <file.cleat> the exported resources in a browser, live
cleat diff <file.cleat> what the output changes compared with a git revision
cleat gen <schema> print Cleat types for an OpenAPI document or CRD file
cleat lsp language server on stdio (used by editors)
cleat argocd generate Argo CD plugin: export $ARGOCD_ENV_CLEAT_FILE (default main.cleat)
cleat argocd params Argo CD plugin: announce the file's inputs as parameters
options:
--stdin read the source from stdin (the file name still places imports)
--root <dir> imports must stay inside this directory
(default: nearest directory with .git, else the file's directory)
export options:
--input <file> input values: a JSON or YAML object
--set <name>=<value> one input (decoded by its declared type); repeatable
--argocd-params read inputs from $ARGOCD_APP_PARAMETERS (Argo CD plugin)
--target <t> plain (default) | argocd (sync waves) | flux (needs --out)
--json emit JSON instead of YAML
--out <dir> output directory (flux)
--flux-source <name> GitRepository name (flux; required)
--flux-path <path> repository path of --out (flux; default: .)
--flux-namespace <ns> namespace for Kustomizations (flux; default: flux-system)
--flux-prefix <prefix> prefix for Kustomization names (flux)
view options (also: --input, --set, --target plain|argocd):
--port <n> port on 127.0.0.1 (default: any free port)
--no-open don't open a browser
--out <file.html> write a self-contained snapshot instead of serving
--diff <rev> mark what changed compared with a git revision (branch, tag, commit)
diff options (also: --input, --set, --target plain|argocd):
--base <rev> the git revision to compare with (required)
--exit-code exit with status 1 when anything changed
cleat check
Parses and type-checks a file and the files it imports, and prints errors
and warnings as file:line:col: error: message. It exits with status 1 if
there are errors. --vcs also lists each refinement obligation and whether
it was proved, refuted, or left to a runtime check.
cleat export
Checks the file, decodes the inputs, evaluates, and prints the exported
documents as YAML (or JSON with --json). Nothing is evaluated if the file
doesn’t check, and every bad input is reported before evaluation starts.
Inputs come from --input <file> (a JSON or YAML object), --set name=value
(repeatable, and decoded by the input’s declared type: a string if the type
accepts one, otherwise JSON), or Argo CD’s ARGOCD_APP_PARAMETERS
(--argocd-params). When an input is given more than once, the last one on
the command line wins.
cleat view
Shows the exported resources in your browser, laid out like a cluster dashboard: resources grouped by kind (Workloads, Network, Config, …), with kind-specific columns (replicas and images, Service ports and what they route to, Ingress routes, ConfigMap keys), a namespace filter and search, and the sync order of components. Selecting a resource shows its containers, labels, relations to other resources and its YAML.
The Map draws the resources as a graph, left to right in the order traffic and configuration flow: Ingresses, then Services (and autoscalers), then workloads, then the ConfigMaps, Secrets, volume claims and service accounts they use, with one box per namespace. References to resources that aren’t in the output appear as dashed nodes, so a broken link is visible where it is. Hovering a resource highlights what it’s connected to.
It also checks the resources against each other and against common mistakes, which the type checker can’t see. Warnings:
- a Service that selects no workload, or targets a named port no selected container has;
- an Ingress, HorizontalPodAutoscaler or PodDisruptionBudget pointing at something that isn’t there;
- a workload whose selector doesn’t match its own pod labels (the API server rejects it);
- an image with no tag or
:latest, so what runs can change on its own; - a NetworkPolicy that blocks all incoming traffic to a workload a Service routes to;
- the same resource defined twice (applying keeps only the last).
Notes, which may be fine: ConfigMaps, Secrets, volume claims and service accounts referenced but not in the output (they may already exist in the cluster), containers without resource requests or limits, and containers behind a Service without a readiness probe.
The page updates within a second when the file, or anything it imports,
changes. Inputs start from --set and --input and can be edited on the
page. The server listens on 127.0.0.1 only. With --out view.html it writes
a self-contained snapshot instead, to attach to a pull request or a ticket.
Two options exist for editor integrations: --state prints the page’s data
once as JSON (with --stdin for unsaved text), and cleat view --page
prints the page itself, which they show and send that data to. Each
resource’s data includes the line and column it’s defined at.
cleat diff
Compares the output with the same program at a git revision (a branch, tag or commit), with the same inputs:
$ cleat diff app.cleat --base main --set image=ghcr.io/acme/api:1.5.0
Comparing app.cleat with main (4dec382d89):
~ Deployment api
+ Service metrics
- ConfigMap old-config
1 added, 1 changed, 1 removed (1 unchanged)
~ Deployment api
@@ -3,7 +3,7 @@
metadata:
name: api
spec:
- replicas: 1
+ replicas: 3
selector:
matchLabels:
app: api
Resources are matched by kind, namespace and name. Every file the program
reads at the revision comes from that revision, including imported modules;
files git ignores (such as downloaded schemas) aren’t in any revision, so
they’re read from the working tree. Inputs that only the working tree
declares are left out for the revision. With --exit-code, it exits with
status 1 when anything changed, for CI.
cleat view app.cleat --diff main shows the same comparison in the
dashboard: new, changed and removed resources are marked in the tables and
on the map, the overview lists them, “Changes only” hides the rest, and a
changed resource’s details show its diff.
cleat gen
Prints the Cleat types generated from an OpenAPI document or a CRD file,
which is what an import * as ns from "schema" sees.
Project root
Imports can’t leave the project root: the nearest directory above the entry
file that contains .git, else the file’s own directory, or --root.
Symbolic links are resolved before the check.
Cleat Language Specification (draft v0.2)
A typed configuration language whose values are exactly JSON. It is aimed at Kubernetes as a replacement for Helm and Kustomize, with TypeScript-style structural types and error messages that point at the exact field and constraint that failed.
Decisions marked (provisional) unblock work and are expected to be revisited.
1. Principles
- Values are JSON. Every value is
null, a boolean, a number, a string, a list, or an object. Nothing else can reach the output. - Types are structural. There are no nominal types and no identity. Two types with the same shape are the same type.
- Exact by default. An object type lists every field it allows, so
replcias: 3is an error...opts into extra fields. - Check early, fail precisely.
cleat checkproves what it can without inputs.cleat exportdecodes inputs against their declared types and checks every annotated value during evaluation. An error names the source location, the value’s path (spec.template.spec.containers[0].ports[0]), and the constraint that failed. - Low ceremony. Dotted keys, defaults, fields filled in from single-value types, conditional fields and comprehensions keep files close to the YAML they produce.
- Evaluation terminates (provisional). No general recursion: a function can only call functions declared above it.
2. Values
null | true | false | <int> | <float> | "<string>" | [v, ...] | { key: v, ... }
- Ints are 64-bit and floats are IEEE doubles. Both serialize as JSON numbers.
JSON has no infinity or NaN, so floats are always finite: a literal, input
or imported number out of range is an error, as are float overflow and
division by zero (
.infand.nanin YAML files are rejected too). Integer overflow is an error as well. - Objects keep the order of their keys. When a value is checked against a
record type, fields filled in from a single-literal type (
apiVersion,kind) come first, then the given fields in source order, then other defaults. - Tuples are lists of fixed length.
3. Types
T ::= Never | null | Bool | Int | Float | Str | Json
| "lit" | 42 | 1.5 | true | false -- literal (singleton) types
| [T] -- list
| (T1, T2, ...) -- tuple (a fixed-length list)
| { [Str]: T } -- map: any keys, values of type T
| { f: T, g?: T, h: T = e, "a/b": T, .. } -- record
| T1 | T2 -- union
| T where φ -- refinement (binder: `self`)
| Name -- type alias
Jsonis the type of every value:null | Bool | Float | Str | [Json] | { [Str]: Json }.Int <: Float, and a literal is a subtype of its base type ("dev" <: Str).- In a record:
f: Tis required.g?: Tis optional. Reading it givesT | null, and anullor missing value is left out of the output.h: T = ehas a default. Callers may omit it; when a value is checked against the record, the default is filled in. Reading it givesT.- A field whose type is a single literal (
kind: "Deployment") has that literal as an implicit default. ..makes the record open: any other key is allowed, with aJsonvalue.- Keys may be quoted:
"app.kubernetes.io/name": Str.
- Type aliases may not be recursive. Use
Jsonfor recursive data. (provisional)
3.1 Subtyping
| Rule | Statement |
|---|---|
| Bottom/top | Never <: T <: Json for every T |
| Numbers | Int <: Float; a literal is a subtype of its base type |
| Unions | A | B <: C iff both are; A <: B | C if either holds |
| Lists | covariant |
| Maps | covariant; a record is a map if every field fits the value type (and, if open, the value type is Json) |
| Records | for each field f of the target: a required field must be present and required in the source; an optional or defaulted field may be absent. The source may have extra fields only if the target is open. Fields are covariant. |
| Refinement | T where φ <: S where ψ needs T <: S and φ ⇒ ψ (§6) |
3.2 Normalization
Unions are flattened, Never members are dropped, and any member that is a
subtype of another member is removed ("dev" | Str becomes Str). Records
with a required Never field, and T where false, become Never.
true | false is Bool.
4. Programs
A file is a sequence of top-level items. Everything except type aliases and imports must be declared before it is used.
type Env = "dev" | "staging" | "prod"
input env: Env // supplied at export time
input replicas: Int where self >= 1 = 2 // with a default
let name = "api" // top-level constant
fn port_name(p: Int) -> Str = "http-\(p)" // functions; the body is one expression
export { kind: "ConfigMap", ... } // each `export` emits documents
input name: T (= default)?declares a value supplied from outside (§8).let name (: T)? = ebinds a constant.fn name(params) (-> T)? = edeclares a function. A parameter may have a default (port: Int = 8080). Calls may use positional or named arguments (f(1, port: 80)).export eemitseas output. Ifeis a list, each element is a separate document.export name (after a, b)? = eexports a named component (§9).- Shadowing is allowed. Facts the checker knows about the outer variable are dropped while the inner one is in scope.
4.1 Imports
Imports follow ECMAScript: the form says what you get, and with { type: ... }
says how to read a file as a value.
import * as k8s from "../schemas/kubernetes-1.37.json" // namespace: a schema's types
import * as base from "./base.cleat" // namespace: a Cleat module
import { Deployment, core.v1.Event as Event } from "../schemas/kubernetes-1.37.json" // named
import { app, part_of } from "./base.cleat"
import defaults from "./defaults.yaml" with { type: "yaml" } // default: data
import settings from "./settings.json" with { type: "json" }
import nginxConf from "./nginx.conf" with { type: "text" } // Str
| Form | Gives |
|---|---|
import * as ns from "f" | Everything in f as ns.x: types (ns.T), functions (ns.f(...)) and constants (ns.c). f is a schema (.json/.yaml, OpenAPI or CRDs) or a .cleat module. |
import { a, b.c as d } from "f" | The named types, functions and constants, unqualified. Qualified schema names (core.v1.Event) can be imported under a short local name. |
import x from "f" with { type: "json" | "yaml" | "text" } | The file’s contents as a constant. json/yaml data is typed from its contents: objects become exact records, scalars are widened ("a" is Str), lists hold the union of their elements, and a YAML file with several documents is a list. text is a Str. |
A default import without a type attribute is an error, as is a type
attribute on a namespace or named import. type is the only supported
attribute. Paths are relative to the importing file.
Imports stay inside the project root. After resolving .. and symlinks,
every imported file, including the imports of imported modules, must lie inside
the root, or the import is an error. The root is --root <dir> if given,
otherwise the nearest enclosing directory containing .git, otherwise the
entry file’s directory. In Argo CD it is the repository checkout. This keeps
a configuration (or a parameter-driven Argo build) from reading files
outside the repository, such as ../../../etc/passwd or a symlink pointing
elsewhere.
A Cleat module may contain types, functions, lets, its own imports and
refines. It may not declare inputs or exports: values come in as
function parameters and leave as return values. A module is checked on its
own; its errors are reported at the import line of the importer, with the
module’s file and position. A module’s refines apply to its own view of a
schema, not to the importer’s. Import cycles are errors.
Schemas are converted to Cleat types (cleat gen <file> prints the result):
| Schema | Cleat type |
|---|---|
$ref | the named type; a reference that closes a cycle becomes Json |
type: object with properties | exact record; fields not in required are optional (?) |
additionalProperties: true, x-kubernetes-preserve-unknown-fields | open record (..) |
type: object with additionalProperties: T | { [Str]: T } |
type: array | [T] |
enum | union of literal types |
x-kubernetes-int-or-string, format: int-or-string | Int | Str |
pattern, minLength, maxLength | Str where ... (patterns Rust’s regex engine rejects are dropped) |
minimum, maximum, exclusive* | Int where ... / Float where ... |
nullable: true | T | null |
x-kubernetes-group-version-kind | apiVersion/kind become required literal fields, so they fill themselves in |
Kubernetes resource.Quantity | Str | Float (accepts cpu: 1 and cpu: "500m") |
| Kubernetes enum fields typed as plain strings | literal unions, from a curated table (KUBERNETES_ENUMS in crates/cleat-core/src/openapi.rs): Service.spec.type, imagePullPolicy, restartPolicy, protocols, access modes, update strategies, operators and about 30 more. Each list is the one in the field’s own description, and only long-stable enums are included, since with can’t widen a stale list. |
Names. OpenAPI definitions keep their group and version:
io.k8s.api.apps.v1.Deployment becomes apps.v1.Deployment. A short name
(Deployment) is added when one candidate is clearly preferred: GA over beta
over alpha, then the higher version. When candidates tie (core.v1.Event
and events.v1.Event), there is no short name, and using it is an error
that lists the qualified names. CRD kinds are named <version>.<Kind>, with
nested objects named after their path (v1.CertificateSpec). The short
Kind points to the storage version. OpenAPI descriptions are shown on
hover.
4.2 Refining types: with and refine
Imported schemas describe what an API accepts, which is often looser than a
project’s policy. T with { ... } makes a narrower copy of a record type:
type SmallDeployment = k8s.Deployment with {
spec.replicas where self <= 10, // add a predicate
spec.template.spec.containers[].image where contains(self, ":"), // `[]`: each list element / map value
spec.strategy.type: "RollingUpdate" | "Recreate", // replace the type
spec.revisionHistoryLimit: Int = 3, // replace the type and set a default
-status, // remove an optional field
}
Paths go through nested records, optional (T | null) values, and named
types. with only narrows: a replacement type must be a subtype of the
original, and only optional fields can be removed. So the result is always
a subtype of the original type, and a value that satisfies your refined type
also satisfies the upstream schema. Required/optional status of a field is
kept. On an open record, f: T may add a field that isn’t declared.
refine T with { ... } narrows a type in place: every reference to T
in the file, including those inside other imported types, sees the
narrowed version.
refine k8s.ServiceSpec with { type: "ClusterIP" | "LoadBalancer" } // project policy
let s = k8s.Service { spec.type: "NodePort" } // error: expected `"ClusterIP" | "LoadBalancer"`
refine on a short name refines the definition it points to
(k8s.core.v1.ServiceSpec). Several refines of the same type stack in
source order. Static checks and runtime checks both use the refined type.
5. Expressions
null true 42 1.5 "text \(expr) more" """multi-line""" [a, b] (a, b) { k: v }
x x.f x.0 xs[i] m["key"] f(a, b, name: c) T { ... }
-x !x a + b a - b a * b a / b a % b a == b a != b a < b ...
a && b a || b a ?? b if c then a else b let x = e; body
match e { pattern => e, ... }
-
Interpolation is
\(expr), so shell${VAR}in container commands is left alone. Interpolated values must beStr,Int,FloatorBool. -
Multi-line strings start on the line after
"""and end at a"""on its own line. The closing line’s indentation is removed from every line, and the newline before the closing quotes is not part of the string. Escapes and interpolation work as in ordinary strings.let conf = """ server { listen \(port); } """ -
a ?? bgivesbwhenaisnull. -
T { ... }checks the object againstTand fills in its defaults. It is not a constructor: the result is an ordinary value. -
xs[i]on a list has typeTand fails at runtime wheniis out of bounds.m["k"]on a map has typeT | null.
5.1 Object literals
{
name: "web", // field
"app.kubernetes.io/name": "web", // quoted key
metadata.labels.app: "web", // dotted key: nests objects
...base, // spread
if env == "prod" then { replicas: 3 }, // conditional fields
for k, v in extra => { [k]: v }, // computed keys from a comprehension
}
Members are applied in order. When a key is set twice and both values are objects, they are merged deeply. Otherwise a duplicate key is an error.
5.2 List literals
[ a, ...rest, if debug then sidecar, for c in containers => c.name ]
The body of a for may be an if without else, which filters:
for c in cs => if c.enabled then c.
5.3 Patterns
Same as v0.1: _, x, x: T (type test), literals, (p, q), { f: p, .. }.
Literal patterns cover their literal type, so a match over
"dev" | "staging" | "prod", or over true and false, can be exhaustive
without _. Type tests narrow unions, which makes discriminated unions on a
kind field work as in TypeScript:
match r {
d: { kind: "Deployment", .. } => d.spec.replicas ?? 1,
_ => 0,
}
5.4 Patching values: with
e with { ... } returns a copy of e with changes applied. It is how
environments specialize a shared base (the role of Kustomize overlays):
import * as base from "./base.cleat"
export base.app("api", image) with {
[kind: "Deployment"].spec.replicas: 3, // set
[kind: "Deployment"].metadata.labels +: { tier: "prod" }, // merge
[kind: "Deployment"].spec.template.spec.containers[name: "api"].env +: [{ name: "LOG", value: "warn" }],
[kind: "Service"].spec.type: "LoadBalancer",
-[kind: "Deployment"].spec.strategy, // remove
}
| Entry | Effect |
|---|---|
path: v | Set the value at path, creating missing objects on the way. |
path +: v | Merge: lists append; objects follow JSON Merge Patch (RFC 7386): nested objects merge, null removes a key, other values replace. |
-path | Remove a field, or the selected list elements. |
| Path segment | Selects |
|---|---|
.f | a field |
[] | every list element or map value |
[2] | one list element |
[name: "api", ...] | the list elements whose fields equal these values |
A selector that matches no element is an error, not a no-op, so a misspelled
[name: "apii"] can’t silently do nothing. When the selector values are
literals and the element types are known, this is caught by cleat check.
Typing. Inside a declared type (a named type such as k8s.Deployment),
every entry is checked against that type and the result keeps the type:
unknown fields are errors with suggestions, values must fit, and required
fields cannot be removed. Selectors with literal values narrow a list of
mixed kinds ([kind: "Service"] patches only the Service members). In
anonymous objects, the result type is updated structurally ({ n: 1 } with { n: "one" } has type { n: "one" }).
5.5 Built-in functions
len, keys, values, str, upper, lower, join, split,
contains, starts_with, ends_with, replace, matches (regex), min,
max, sort, to_json, to_yaml, b64encode, error. error(msg) has type Never and
aborts evaluation with msg.
6. Refinements
T where φ restricts T to values satisfying the predicate φ, in which
self is the value. In a field or parameter, the field or parameter’s name
may be used instead of self. A parameter’s predicate may mention earlier
parameters.
type DnsLabel = Str where len(self) <= 63 && matches(self, "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$")
type Port = Int where 1 <= self && self <= 65535
fn clamp(lo: Int, hi: Int where hi >= lo, x: Int) -> Int = ...
Predicates may use any expression of the language. Every expression is pure and total, so predicates can always be evaluated.
Static. cleat check turns each place a value flows into a refined type
into a verification condition (VC). VCs decided by constant folding or by
known facts (path conditions, other refinements) are reported: a refuted VC
is a compile error. All other VCs become runtime checks. An SMT backend may
later decide more of them statically; it is not required.
Dynamic. During evaluation, every value that crosses an annotation is
checked against the annotated type: inputs, function arguments and results,
annotated lets, and T { ... }. These checks enforce both structure and
refinements.
7. Checking and evaluation
- Parse.
- Resolve types. Check expressions bidirectionally against expected types. Diagnostics are field-aware: an unknown field suggests the closest known name.
- Generate VCs (§6).
- Evaluate, in order: inputs (§8), then top-level
lets, thenexports. Annotated boundaries check and complete values (fill in defaults, drop optionalnulls). - Serialize to YAML (multiple documents) or JSON. Strings are always quoted
where YAML would otherwise read them as something else (
no,1.0,~).
8. Inputs
An input’s value is decoded according to its declared type:
- From a JSON or YAML file (
--input values.json), which holds an object of inputs. - From the command line (
--set replicas=3). If the type accepts the raw string, the input is that string. Otherwise the string is parsed as JSON. So--set name=webis a string and--set replicas=3is an int. - From Argo CD (
--argocd-params, used bycleat argocd generate), which reads theARGOCD_APP_PARAMETERSenvironment variable. String parameters are decoded as with--set; array and map parameters as lists and maps.
A missing input without a default, an unknown input name, or a value that does not fit the type is an error before evaluation starts. The error names the input and shows the expected type.
9. Components and dependencies
export db = [ postgres_deployment, postgres_service ]
export config = [ app_config ]
export app after db, config = [ api_deployment, api_service ]
A named export is a component. after lists the components that must be
healthy before it is applied. Names must exist, a component cannot depend on
itself, and cycles are compile errors that show the cycle (a -> b -> a).
Unnamed exports belong to an implicit component called default with no
dependencies.
A component’s wave is 0 if it has no dependencies, otherwise one more than the largest wave among its dependencies. The output target decides how dependencies reach the cluster:
| Target | Output |
|---|---|
plain | All documents as one YAML stream, in wave order. |
argocd | As plain, with metadata.annotations["argocd.argoproj.io/sync-wave"] set to the component’s wave. A sync-wave annotation already present in the document is kept. |
flux | <out>/<component>/resources.yaml per component, plus <out>/flux-kustomizations.yaml with one Flux Kustomization per component (kustomize.toolkit.fluxcd.io/v1). Each has path pointing at its directory, dependsOn from after, and wait: true, so dependents wait for health, not just for the apply. |
10. Roadmap
Core language, evaluator, YAML/JSON output, typed inputs.Done.Components withDone.after; Argo CD sync waves; FluxdependsOn.OpenAPI and CRD import (§4.1).Done.Done.e with { ... }value patches with key selectors (§5.4).Argo CD config management plugin: sidecar image,Done.plugin.yaml, inputs announced as UI parameters; tested end to end on Argo CD v3.5.Functions, constants, imports and refines in imported modules.Done.
11. Open questions
- Totality: keep the no-recursion rule, or allow recursion with a fuel limit?
- Strategic-merge semantics for
withon keyed lists without an explicit selector. - Defaults inside
typedeclarations are checked with no variables in scope, so they can’t refer to inputs orlets. Is that the right rule? - Should components be able to set an explicit wave offset, or Flux options
like
timeout, per component? - The curated Kubernetes enum table (§4.1) must track new Kubernetes releases. Should it instead be derived from field descriptions (“Valid options are …”) when a schema is imported?
- Recursive types: should aliases be allowed to be recursive, so schemas
like
JSONSchemaPropskeep their full structure instead ofJson?
How Cleat is built
Cleat is written in Rust as a small workspace:
| Crate | What it is |
|---|---|
cleat-core | The language: lexer, parser, checker, evaluator, schema import and output. no_std with alloc, and no I/O. |
cleat | The cleat command, the language server and the Argo CD plugin, on a real file system. |
cleat-wasm | cleat-core compiled to WebAssembly, for the playground. |
A core without I/O
cleat-core never reads a file, the clock or the environment. Everything
outside the program text goes through a Host that the application
provides:
#![allow(unused)]
fn main() {
pub trait Host {
/// The file an import names, confined to the project root.
fn resolve(&self, base: Option<&str>, root: Option<&str>, spec: &str) -> Result<String, String>;
/// A version of a file's contents, so caches know when it changed.
fn stamp(&self, file: &str) -> Stamp;
fn read(&self, file: &str) -> Result<String, String>;
fn parse_yaml(&self, text: &str) -> Result<Vec<Value>, String>;
// ...
}
}
The command line uses a file-system host, whose overlay lets the language server see unsaved editor buffers. The playground uses an in-memory host. Because the core can’t do anything else, evaluation is hermetic by construction: the same files give the same output.
A Session holds a host together with its caches (loaded and checked
modules, compiled regular expressions), keyed by the stamps of every file a
result was computed from. The core is built in CI for a target that has no
std at all, so it stays portable.
Allocation
Today the core uses the global allocator through alloc’s collections, and
each final program chooses that allocator. The plan for later is explicit,
fallible allocation on stable Rust: the AST, types and values in arenas the
caller owns and passes in, grown with try_reserve so running out of memory
is an ordinary error. That would let the Argo CD sidecar and the browser cap
the memory of one evaluation. When Rust’s allocator API stabilizes, the
allocator itself can be passed explicitly.
Supply chain
GitHub Actions are pinned to commit SHAs and updated by Dependabot, base images are pinned by digest, and every download (Kubernetes schemas, kind, Argo CD, mdBook) is checked against a recorded SHA-256.