Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

Cleat Cleat

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

HelmKustomizeCleat
How you write configGo templates over YAML textYAML plus patchesA typed language that outputs YAML
Checks against the Kubernetes APIAfter renderingAfter renderingWhile writing, with the field and a fix
Inputsvalues.yaml (optional JSON Schema)noneTyped inputs with defaults and constraints
Variations per environmentvalues filesoverlaysmodules and typed with patches
Ordering between resourceshooks and weightsby kind onlyafter → 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

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 = e checks e against T and fills in defaults (here pullPolicy).
  • if without else inside a list or object adds the element only when the condition holds.
  • for x in xs => e is a comprehension. In an object, it computes keys.
  • Dotted keys nest objects: metadata.labels.app: "api" is metadata: { labels: { app: "api" } }.
  • ...e spreads 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: 8080 or "http") and quantities (cpu: "500m" or cpu: 0.5);
  • enums: those in the spec, plus about 40 that the spec leaves as plain strings, like Service.spec.type, imagePullPolicy and restartPolicy;
  • 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",
}
EntryEffect
path: vSet the value at path, creating missing objects on the way.
path +: vMerge: lists append; objects follow JSON Merge Patch (nested objects merge, null removes a key).
-pathRemove a field, or the selected list elements.
Path segmentSelects
.fa 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:

TargetOutput
plain (default)One YAML stream, in wave order.
argocdThe same, with the argocd.argoproj.io/sync-wave annotation set on every document (an existing one is kept). See Argo CD.
fluxA 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_FILEThe entry file in the application’s directory (default main.cleat).
CLEAT_TARGETargocd (default, adds sync waves) or plain.

What you get

  • Parameters in the UI. Each input in 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 after dependencies (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
OptionMeaning
--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 view dashboard 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 the let that 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

SettingMeaning
cleat.serverPathThe cleat binary. Default: target/debug/cleat or target/release/cleat in the workspace, else cleat on PATH.
cleat.compareWithA 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.exportArgsExtra 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: 3 is an error. .. opts into extra fields.
  • Check early, fail precisely. cleat check proves what it can without inputs. cleat export decodes 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 (.inf and .nan in 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
  • Json is 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: T is required.
    • g?: T is optional. Reading it gives T | null, and a null or missing value is left out of the output.
    • h: T = e has a default. Callers may omit it; when a value is checked against the record, the default is filled in. Reading it gives T.
    • 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 a Json value.
    • Keys may be quoted: "app.kubernetes.io/name": Str.
  • Type aliases may not be recursive. Use Json for recursive data. (provisional)

3.1 Subtyping

RuleStatement
Bottom/topNever <: T <: Json for every T
NumbersInt <: Float; a literal is a subtype of its base type
UnionsA | B <: C iff both are; A <: B | C if either holds
Listscovariant
Mapscovariant; a record is a map if every field fits the value type (and, if open, the value type is Json)
Recordsfor 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.
RefinementT 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)? = e binds a constant.
  • fn name(params) (-> T)? = e declares a function. A parameter may have a default (port: Int = 8080). Calls may use positional or named arguments (f(1, port: 80)).
  • export e emits e as output. If e is a list, each element is a separate document. export name (after a, b)? = e exports 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
FormGives
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):

SchemaCleat type
$refthe named type; a reference that closes a cycle becomes Json
type: object with propertiesexact record; fields not in required are optional (?)
additionalProperties: true, x-kubernetes-preserve-unknown-fieldsopen record (..)
type: object with additionalProperties: T{ [Str]: T }
type: array[T]
enumunion of literal types
x-kubernetes-int-or-string, format: int-or-stringInt | Str
pattern, minLength, maxLengthStr where ... (patterns Rust’s regex engine rejects are dropped)
minimum, maximum, exclusive*Int where ... / Float where ...
nullable: trueT | null
x-kubernetes-group-version-kindapiVersion/kind become required literal fields, so they fill themselves in
Kubernetes resource.QuantityStr | Float (accepts cpu: 1 and cpu: "500m")
Kubernetes enum fields typed as plain stringsliteral 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 be Str, Int, Float or Bool.

  • 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 ?? b gives b when a is null.

  • T { ... } checks the object against T and fills in its defaults. It is not a constructor: the result is an ordinary value.

  • xs[i] on a list has type T and fails at runtime when i is out of bounds. m["k"] on a map has type T | 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
}
EntryEffect
path: vSet the value at path, creating missing objects on the way.
path +: vMerge: lists append; objects follow JSON Merge Patch (RFC 7386): nested objects merge, null removes a key, other values replace.
-pathRemove a field, or the selected list elements.
Path segmentSelects
.fa 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

  1. Parse.
  2. Resolve types. Check expressions bidirectionally against expected types. Diagnostics are field-aware: an unknown field suggests the closest known name.
  3. Generate VCs (§6).
  4. Evaluate, in order: inputs (§8), then top-level lets, then exports. Annotated boundaries check and complete values (fill in defaults, drop optional nulls).
  5. 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=web is a string and --set replicas=3 is an int.
  • From Argo CD (--argocd-params, used by cleat argocd generate), which reads the ARGOCD_APP_PARAMETERS environment 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:

TargetOutput
plainAll documents as one YAML stream, in wave order.
argocdAs 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

  1. Core language, evaluator, YAML/JSON output, typed inputs. Done.
  2. Components with after; Argo CD sync waves; Flux dependsOn. Done.
  3. OpenAPI and CRD import (§4.1). Done.
  4. e with { ... } value patches with key selectors (§5.4). Done.
  5. Argo CD config management plugin: sidecar image, plugin.yaml, inputs announced as UI parameters; tested end to end on Argo CD v3.5. Done.
  6. 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 with on keyed lists without an explicit selector.
  • Defaults inside type declarations are checked with no variables in scope, so they can’t refer to inputs or lets. 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 JSONSchemaProps keep their full structure instead of Json?

How Cleat is built

Cleat is written in Rust as a small workspace:

CrateWhat it is
cleat-coreThe language: lexer, parser, checker, evaluator, schema import and output. no_std with alloc, and no I/O.
cleatThe cleat command, the language server and the Argo CD plugin, on a real file system.
cleat-wasmcleat-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.