Skip to content

CUE Features Walkthrough

To work on Timoni modules a basic understanding of CUE is required. This guide will walk you through the core features of CUE and how to use them to generate Kubernetes objects.

We'll start with a basic Kubernetes Service definition, and we'll gradually add more features to reduce the boilerplate and improve the validation of the generated YAML.

Command Line Tool

Before we begin, make sure you have the CUE CLI installed. To install CUE with Homebrew, run:

brew install cue

For more installation options, follow the instructions from the official documentation.

It is recommended to use the same CUE version as the one embedded in Timoni, which can be found by running: timoni version.

Timoni CUE dependency

Note that Timoni embeds the CUE engine, so you don't need to install it separately in order to use Timoni. The CUE CLI is only required when developing modules to format the CUE files before publishing the modules to container registries.

CUE comes with a rich set of CLI commands. Throughout this guide, we'll be using the following commands:

  • cue fmt - format CUE files
  • cue eval - evaluate CUE expressions
  • cue vet - validate CUE definitions

Builtin Types

CUE defines the following type hierarchy:

  • null
  • bool
  • string
  • bytes
  • number (int and float)
  • struct
  • list
  • _ (any type)
  • _|_ (error type)

Structs and Fields

Struct is the most important composite type in CUE, its members are called fields. A field is a key-value pair, where the key is a string and the value is any CUE type.

We'll use a Kubernetes Service as an example to demonstrate how to define a struct in CUE.

package main

nginxSvc: {
    apiVersion: "v1"
    kind:       "Service"
    metadata: {
        name:      "nginx"
        namespace: "default"
    }
    spec: {
        selector: "app.kubernetes.io/name": "nginx"
        ports: [{
            name:       "http"
            port:       80
            targetPort: 80
        }]
    }
}
apiVersion: v1
kind: Service
metadata:
  name: nginx
  namespace: default
spec:
  selector:
    app.kubernetes.io/name: nginx
  ports:
    - name: http
      port: 80
      targetPort: 80

To generate the Kubernetes Service YAML, save the CUE definition in a file called service.cue, and used the cue eval command to evaluate the nginxSvc struct and output it in YAML format:

cue eval -e nginxSvc --out yaml

Field Immutability

In CUE structs are merged, which means you can define a struct with the same name in multiple places in the same package, as long as the fields are not duplicated.

For example, you can add a label to the selector in a new code block:

package main

nginxSvc: {
    apiVersion: "v1"
    kind:       "Service"
    metadata: {
        name:      "nginx"
        namespace: "default"
    }
    spec: {
        selector: "app.kubernetes.io/name": "nginx"
        ports: [{
            name:       "http"
            port:       80
            targetPort: 80
        }]
    }
}

nginxSvc: spec: selector: "app.kubernetes.io/component": "proxy"
apiVersion: v1
kind: Service
metadata:
  name: nginx
  namespace: default
spec:
  selector:
    app.kubernetes.io/name: nginx
    app.kubernetes.io/component: proxy
  ports:
    - name: http
      port: 80
      targetPort: 80

In CUE fields are immutable, which means that once a field is set to a concrete value, its value cannot be changed.

To demonstrate immutability, let's try to rename our service by adding a new code block to the service.cue file:

nginxSvc: metadata: name: "nginx-2"

If you run the eval command, you'll notice that the name field reports an error:

$ cue eval -e nginxSvc --out yaml
nginxSvc.metadata.name: conflicting values "nginx-2" and "nginx":
    ./service.cue:5:14
    ./service.cue:18:27

Schema Definitions

CUE definitions, indicated by an identifier starting with #, are used to define schema against which concrete values such as structs can be validated.

The following example demonstrates how to define a basic #Service schema and how to assign it to the nginxSvc struct:

package main

#Service: {
    apiVersion: string
    kind:       string
    metadata: {
        name:      string
        namespace: string
    }
    spec: {
        selector: [string]: string
        ports: [{
            name:       string
            port:       int
            targetPort: int | string
        }]
    }
}
package main

// Set the schema
nginxSvc: #Service

// Set the concrete values
nginxSvc: {
    apiVersion: "v1"
    kind:       "Service"
    metadata: {
        name:      "nginx"
        namespace: "default"
    }
    spec: {
        selector: "app.kubernetes.io/name": "nginx"
        ports: [{
            name:       "http"
            port:       80
            targetPort: 80
        }]
    }
}
apiVersion: v1
kind: Service
metadata:
  name: nginx
  namespace: default
spec:
  selector:
    app.kubernetes.io/name: nginx
  ports:
    - name: http
      port: 80
      targetPort: 80

Save the schema in a file called schema.cue. To make our service use the #Service schema, in service.cue we'll have to change its definition to nginxSvc: #Service.

To validate the nginxSvc struct against the #Service schema, run:

cue vet --concrete

While the current schema is very basic, it has reduced by a lot the mistakes that can be made when defining a Kubernetes Service.

  • We can no longer define a Service without specifying all the fields present in the schema.
  • We can't set a field value to a different type than the one defined in the schema.
  • We can't add fields that are not present in the schema, a typo in a field name will not go unnoticed.

To demonstrate the schema validation, let's try to change the port field in service.cue to a string e.g. port: "80".

If you run the vet or eval command, you'll notice that the port field reports an error:

$ cue eval -e nginxSvc --out yaml
nginxSvc.spec.ports.0.port: conflicting values int and "80" (mismatched types int and string):
    ./schema.cue:14:16
    ./service.cue:17:16

CUE allows setting multiple types for a field, in the example above, the targetPort field can be either an int or a string. To demonstrate this, let's change the targetPort field in service.cue to a string, e.g. targetPort: "http".

If you run the eval command, you'll notice that the targetPort field passes validation and the output YAML contains the string value.

Default values

In CUE, you can set default values for fields using the * operator, e.g. apiVersion: string | *"v1".

To reduce the boilerplate, we can define default values for fields such as apiVersion, kind and namespace.

package main

#Service: {
    apiVersion: string | *"v1"
    kind:       string | *"Service"
    metadata: {
        name:      string
        namespace: string | *"default"
    }
    spec: {
        selector: [string]: string
        ports: [{
            name:       string
            port:       int
            targetPort: int | string
        }]
    }
}
package main

// Set the schema
nginxSvc: #Service

nginxSvc: {
    metadata: name: "nginx"
    spec: {
        selector: "app.kubernetes.io/name": "nginx"
        ports: [{
            name:       "http"
            port:       80
            targetPort: "http"
        }]
    }
}
apiVersion: v1
kind: Service
metadata:
  name: nginx
  namespace: default
spec:
  selector:
    app.kubernetes.io/name: nginx
  ports:
    - name: http
      port: 80
      targetPort: http

The fields with default values set in schema can be omitted from the struct definition.

If you run the eval command to generate the YAML, you'll notice that the fields with defaults are present in output.

Required Fields

To improve the validation of the #Service schema, we can mark fields such as name and port as required using the ! operator e.g. name!: string.

package main

#Service: {
    apiVersion: string | *"v1"
    kind:       string | *"Service"
    metadata: {
        name!:     string
        namespace: string | *"default"
    }
    spec!: {
        selector: [string]: string
        ports: [{
            name:      string
            port!:      int
            targetPort: int | string
        }]
    }
}
package main

// Set the schema
nginxSvc: #Service

nginxSvc: {
    metadata: {
        name: "nginx"
    }
    spec: {
        selector: "app.kubernetes.io/name": "nginx"
        ports: [{
            name:       "http"
            port:       80
            targetPort: "http"
        }]
    }
}
apiVersion: v1
kind: Service
metadata:
  name: nginx
  namespace: default
spec:
  selector:
    app.kubernetes.io/name: nginx
  ports:
    - name: http
      port: 80
      targetPort: http

To demonstrate the required fields validation, let's try to remove the port field from service.cue.

If you run the vet or eval command, you'll notice that the port field reports an error:

$ cue eval -e nginxSvc --out yaml
nginxSvc.spec.ports.0.port: field is required but not present:
    ./schema.cue:14:4
    ./service.cue:4:11

Optional Fields

To make the #Service schema match the Kubernetes specification, we can mark fields such as selector and ports as optional, using the ? operator e.g. selector?: [string]: string.

We'll also add labels and annotations as optional fields to the metadata struct. And finally, we'll extend the Service spec with type, clusterIP, externalName and protocol as optional fields to complete the schema.

package main

#Service: {
    apiVersion: string | *"v1"
    kind:       string | *"Service"
    metadata: {
        name!:     string
        namespace: string | *"default"
        labels?: [string]:      string
        annotations?: [string]: string
    }
    spec!: {
        type?:         string
        clusterIP?:    string
        externalName?: string
        selector?: [string]: string
        ports?: [...{
            name?:       string
            protocol:    *"TCP" | "UDP" | "SCTP"
            port!:       int & >=1 & <=65535
            targetPort?: int | string
        }]
    }
}
package main

// Set the schema
nginxSvc: #Service

nginxSvc: {
    metadata: {
        name:      "nginx"
        namespace: "default"
    }
    spec: {
        type: "ClusterIP"
        selector: "app.kubernetes.io/name": "nginx"
        ports: [{
            name: "http"
            port: 80
        }]
    }
}
apiVersion: v1
kind: Service
metadata:
  name: nginx
  namespace: default
spec:
  selector:
    app.kubernetes.io/name: nginx
  ports:
    - name: http
      protocol: TCP
      port: 80

To demonstrate the optional fields validation, let's try to remove the targetPort field from service.cue.

If you run the eval command to generate the YAML, you'll notice that the targetPort field is not present in the output.

Field Constraints

To improve the validation of the #Service schema, we can add constraints to its fields.

For example, we can constrain the name field to match the Kubernetes naming convention, using a regular expression e.g. name!: =~"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$".

We can also constrain the port field to be in the range 1-65535, using the >= and <= operators e.g. port!: >=1 & <=65535.

We can also constrain the type field to match one of the allowed values, using the | operator e.g. type: *"ClusterIP" | "NodePort" | "LoadBalancer" | "ExternalName".

package main

#Service: {
    apiVersion: string | *"v1"
    kind:       string | *"Service"
    metadata: {
        name!:     string & =~"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$"
        namespace: string | *"default"
        labels?: [string]:      string
        annotations?: [string]: string
    }
    spec!: {
        type:          *"ClusterIP" | "NodePort" | "LoadBalancer" | "ExternalName"
        clusterIP?:    string
        externalName?: string
        selector?: [string]: string
        ports?: [...{
            name?:       string
            protocol:    *"TCP" | "UDP" | "SCTP"
            port!:       int & >=1 & <=65535
            targetPort?: int | string
        }]
    }
}
package main

// Set the schema
nginxSvc: #Service

nginxSvc: {
    metadata: name: "nginx"
    spec: {
        selector: "app.kubernetes.io/name": "nginx"
        ports: [{
            name:       "http"
            port:       80
            targetPort: "http"
        }]
    }
}
apiVersion: v1
kind: Service
metadata:
  name: nginx
  namespace: default
spec:
  selector:
    app.kubernetes.io/name: nginx
  ports:
    - name: http
      protocol: TCP
      port: 80
      targetPort: http

To demonstrate the field constraints validation, let's try to change the port field in service.cue to a value outside the range 1-65535, e.g. port: 65536 and let's add a disallowed character to the name field, e.g. name: "nginx_proxy".

If you run the vet or eval command, you'll notice both the name and port fields report an error:

$ cue eval -e nginxSvc --out yaml
nginxSvc.metadata.name: invalid value "nginx_proxy" (out of bound =~"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$"):
    ./schema.cue:7:14
    ./service.cue:5:9
nginxSvc.spec.ports.0.port: invalid value 65536 (out of bound <=65535):
    ./schema.cue:20:29
    ./service.cue:16:10

Conditional Fields

In CUE, you can use if statements to add fields to a schema definition conditionally.

For example, we can add the externalName field to the #Service schema only when the type is set to ExternalName.

package main

#Service: {
    apiVersion: string | *"v1"
    kind:       string | *"Service"
    metadata: {
        name!:     string & =~"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$"
        namespace: string | *"default"
    }
    spec!: {
        type: *"ClusterIP" | "NodePort" | "LoadBalancer" | "ExternalName"
        if type == "ExternalName" {
            externalName!: string
        }
        clusterIP?:   string
        selector?: [string]: string
        ports?: [...{
            name?:       string
            protocol:    *"TCP" | "UDP" | "SCTP"
            port!:       int & >=1 & <=65535
            targetPort?: int | string
        }]
    }
}
package main

// Set the schema
nginxSvc: #Service

nginxSvc: {
    metadata: name: "nginx"
    spec: {
        selector: "app.kubernetes.io/name": "nginx"
        ports: [{
            name:       "http"
            port:       80
            targetPort: "http"
        }]
    }
}
apiVersion: v1
kind: Service
metadata:
  name: nginx
  namespace: default
spec:
  type: ClusterIP
  selector:
    app.kubernetes.io/name: nginx
  ports:
    - name: http
      protocol: TCP
      port: 80
      targetPort: http

To demonstrate the condition field constraint, let's add externalName: "example.com" in service.cue without setting the type to ExternalName.

If you run the vet or eval command, you'll notice that the externalName field reports an error:

$ cue eval -e nginxSvc --out yaml
nginxSvc.spec.externalName: field not allowed:
    ./schema.cue:10:9
    ./service.cue:10:3

Field References

In CUE, you can reference field values using dot notation paths. For example, to reference the name field from the metadata struct, you can use the path metadata.name anywhere in spec.

package main

nginxSvc: #Service & {
    metadata: {
        name: "nginx"
        // Reference the metadata name field
        namespace: name
    }
    spec: {
        // Reference the metadata name field
        selector: "app.kubernetes.io/name": metadata.name
        ports: [{
            name: "http"
            port: 80
            // Reference the port name field
            targetPort: name
        }]
    }
}
apiVersion: v1
kind: Service
metadata:
  name: nginx
  namespace: nginx
spec:
  type: ClusterIP
  selector:
    app.kubernetes.io/name: nginx
  ports:
    - name: http
      protocol: TCP
      port: 80
      targetPort: http

Note that CUE references a value from the nearest enclosing scope, demonstrated above by referencing the name field in both metadata and ports. Inside the metadata struct, the name field references the metadata.name value, while inside the ports list, the name field references the ports[0].name value.

Aliases

In CUE, an alias defines a local value that is not a member of a struct and is omitted from the output. Aliases are useful when you want to perform intermediate calculations and reuse the result in multiple places within the same struct.

For example, we can define an alias for the app name, and use it to set the name and namespace fields in metadata, and the app.kubernetes.io/name label in selector.

package main

nginxSvc: #Service & {
    let appName = "nginx"
    metadata: {
        name:      appName
        namespace: appName
    }
    spec: {
        selector: "app.kubernetes.io/name": appName
        ports: [{
            name: "http"
            port: 80
        }]
    }
}
apiVersion: v1
kind: Service
metadata:
  name: nginx
  namespace: nginx
spec:
  type: ClusterIP
  selector:
    app.kubernetes.io/name: nginx
  ports:
    - name: http
      protocol: TCP
      port: 80

Interpolation

Cue supports interpolation in strings, bytes and field names with \(expr). The expression can be any CUE expression, including references to other fields.

For example, we can declare an alias named kubeLabel and use it to interpolate the domain name in the selector labels. We can also use interpolation to add the metadata.name as a prefix to the port name.

package main

nginxSvc: #Service & {
    let kubeLabel = "app.kubernetes.io"
    metadata: {
        name:      "nginx"
        namespace: name
    }
    spec: {
        selector: {
            "\(kubeLabel)/name":      metadata.name
            "\(kubeLabel)/component": "proxy"
        }
        ports: [{
            name: "http-\(metadata.name)"
            port: 80
        }]
    }
}
apiVersion: v1
kind: Service
metadata:
  name: nginx
  namespace: nginx
spec:
  type: ClusterIP
  selector:
    app.kubernetes.io/name: nginx
    app.kubernetes.io/component: proxy
  ports:
    - name: http-nginx
      protocol: TCP
      port: 80

Setting prefixes and suffixes to field values can also be accomplished using the + operator, e.g.:

  • name: (metadata.name) + "-http"
  • name: "http-" + (metadata.name)

List Comprehensions

Similar to Python and other languages, CUE supports list comprehensions using the [for key, value in list { result }] syntax.

For example, we can generate a list of Service ports from a list of port numbers.

package main

nginxSvc: #Service & {
    let appPorts = [80, 443]
    metadata: name: "nginx"
    spec: {
        selector: "app.kubernetes.io/name": "nginx"
        ports: [for i, p in appPorts {
            name: "http-\(i)"
            port: p
        }]
    }
}
apiVersion: v1
kind: Service
metadata:
  name: nginx
  namespace: default
spec:
  type: ClusterIP
  selector:
    app.kubernetes.io/name: nginx
  ports:
    - name: http-0
      protocol: TCP
      port: 80
    - name: http-1
      protocol: TCP
      port: 443

Comprehensions can also be used with conditionals, for example to generate the list of ports, but only for port numbers in the range 80-443: [for i, p in appPorts if p >= 80 & p <= 443 { result }].

Embedding

Similar to OOP composition, CUE allows the embedding of a definition into another. Embedding is useful when you want to create specialised schemas which further constrain the fields of the base schema.

For example, we can embed the #Service schema into a #HeadlessService schema, and set concrete values to the type and clusterIP fields.

package main

#HeadlessService: #Service & {
    spec!: {
        type:      "ClusterIP"
        clusterIP: "None"
    }
}

#Service: {
    apiVersion: string | *"v1"
    kind:       string | *"Service"
    metadata: {
        name!:     string & =~"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$"
        namespace: string | *"default"
        labels?: [string]:      string
        annotations?: [string]: string
    }
    spec!: {
        type:         *"ClusterIP" | "NodePort" | "LoadBalancer" | "ExternalName"
        appProtocol?: string
        clusterIP?:   string
        if type == "ExternalName" {
            externalName!: string
        }
        selector?: [string]: string
        ports?: [...{
            name?:       string
            protocol:    *"TCP" | "UDP" | "SCTP"
            port!:       int & >=1 & <=65535
            targetPort?: int | string
        }]
    }
}
package main

nginxSvc: #HeadlessService & {
    metadata: name: "nginx"
    spec: {
        selector: "app.kubernetes.io/name": "nginx"
        ports: [{
            name:       "http"
            port:       80
        }]
    }
}
apiVersion: v1
kind: Service
metadata:
  name: nginx
  namespace: default
spec:
  type: ClusterIP
  clusterIP: None
  selector:
    app.kubernetes.io/name: nginx
  ports:
    - name: http
      protocol: TCP
      port: 80

To make our service use the #HeadlessService schema, we'll have to change its definition to nginxSvc: #HeadlessService.

While we can configure the metadata and ports, we can no longer set the type and clusterIP fields, as they are set by the #HeadlessService schema.