Skip to content

Get Started with Timoni Modules

This guide will walk you through the process of creating a new Timoni module. We'll explore the structure of the created module, and the various development commands that Timoni provides to aid module development.

Furthermore, this guide provides an overview of the templating system, including the module configuration values, the Kubernetes templates, and how to generate Kubernetes objects from these templates.

Initialize a module

To create a module in the current directory, run the following command:

timoni mod init myapp \
--blueprint oci://ghcr.io/stefanprodan/timoni/blueprints/starter

The starter blueprint will create a simple module that deploys a NGINX web server.

Module structure

The init command creates a directory named myapp with the following structure:

myapp
├── cue.mod
│   ├── gen # Kubernetes APIs and CRDs schemas   ├── pkg # Timoni APIs schemas   └── module.cue # Module metadata
├── templates
│   ├── config.cue # Config schema and default values   ├── deployment.cue # Kubernetes Deployment template   └── service.cue # Kubernetes Service template
├── timoni.cue # Timoni entry point
├── timoni.ignore # Timoni ignore rules
├── values.cue # Timoni values placeholder 
├── LICENSE # Module license
└── README.md # Module documentation

Navigate to the root directory of the new module with cd myapp, this is where we'll be working from now on.

Development commands

Timoni comes with a set of commands that help with module development.

At most times, after making changes, you'll be using the timoni mod vet command to verify that the module config and the Kubernetes templates are valid.

Build and apply the module

To build a module instance, run the following command:

timoni -n test build nginx .

The build command generates a Kubernetes Deployment and Service, and prints the Kubernetes resources to stdout in YAML format.

If you inspect the output, you'll notice that the Kubernetes metadata matches the instance name and namespace specified in the build command:

apiVersion: apps/v1
kind: Deployment
metadata:
  labels:
    app.kubernetes.io/managed-by: timoni
    app.kubernetes.io/name: nginx
    app.kubernetes.io/version: 0.0.0-devel
  name: nginx
  namespace: test

By convention, Timoni uses the app.kubernetes.io/name label to set the Deployment selector and the Service selector.

The app.kubernetes.io/version label is used to track the module version from the container registry, and it's set to 0.0.0-devel by default, when building a module locally.

To create the module instance on a Kubernetes cluster:

timoni -n test apply nginx .

The apply command will create the Kubernetes resources and will wait for the Deployment and Service to become ready. If the test namespace doesn't exist, Timoni will create it.

Diff changes

When making changes to the module, you can use the timoni apply --diff flag to see the differences between the in-cluster resources and the newly generated ones.

To delete the module instance:

timoni -n test delete nginx .

The delete command will remove the Kubernetes Deployment and Service from the cluster, but will leave the test namespace intact, because the namespace resource is not part of the module.

Build with custom values

For debugging purposes, the blueprint contains a debug_values.cue file that can be used to test the module with custom values.

To build a module instance with the debug values, run the following command:

timoni -n test build nginx . --values debug_values.cue

If you inspect the output, you'll notice that the Deployment container image has changed from docker.io/nginx:1-alpine to docker.io/nginx:1-alpine-slim, as specified in the debug_values.cue file.

Ignore rules

Note that the debug_values.cue file is listed in timoni.ignore, and will be excluded when publishing the module to a container registry. The debug values are meant to be used only for local testing while developing the module.

Vetting the module

The vet command is your best friend when developing modules. It verifies that the module structure is compliant with the Timoni specification, then it builds the module and validates the generated Kubernetes resources against their CUE schemas.

To vet the module with default values, run the following command:

timoni mod vet --name nginx
INF vetting with default values
INF Deployment/default/nginx valid resource
INF Service/default/nginx valid resource
INF docker.io/nginx:1-alpine valid image (digest missing)
INF timoni.sh/myapp valid module

The vet command will print the list of Kubernetes resources and their validation status, along with the container images referenced in the module.

It is also possible to verify the module using the debug values:

timoni mod vet --debug --name nginx --namespace test
INF vetting with debug values
INF Deployment/test/nginx valid resource
INF Service/test/nginx valid resource
INF docker.io/nginx:1-alpine-slim valid image (digest missing)
INF timoni.sh/myapp valid module

If the vet command encounters an invalid definition, it will print the error message, the file and line number where the error occurred.

Format the module files

Similar to Go, CUE has a built-in code formatter that can be used to format CUE files.

To format all files in a module, run the following command:

cue fmt ./...

It is recommended to run this command after making changes to a module. Most editors have a CUE plugin that can run the cue fmt command automatically when saving a file.

Update the Kubernetes schemas

To ensure that the Kubernetes resources defined in a module are in conformance with their OpenAPI schema, Timoni offers a command for vendoring CUE definitions generated from the Kubernetes builtin APIs.

To update the schemas to the latest Kubernetes stable release, run the following command:

timoni mod vendor k8s

The vendor command will download the Kubernetes schemas from GitHub container registry, and will update the CUE definitions from the cue.mod/gen/k8s.io directory.

Update the Timoni schemas

Timoni comes with a set of CUE definitions (schemas and generators), that are used to reduce the boilerplate code when developing modules. These definitions are included in the modules generated with timoni mod init, and are vendored in the cue.mod/pkg/timoni directory.

To update the Timoni schemas to the latest version, run the following command from within the module root:

timoni artifact pull oci://ghcr.io/stefanprodan/timoni/schemas -o cue.mod/pkg

Schemas versioning

The schemas are published with every Timoni release, each Timoni version has a corresponding schemas artifact tag. While the Timoni API is in alpha, the schemas may change between releases in a non-backwards compatible way.

Templates overview

The templates directory contains the module configuration schema and the Kubernetes resources templates.

Config definition

The config.cue file contains a schema definition called #Config that is used to specify which input fields can be configured by end-users when applying a module instance.

For each input field, the #Config definition can specify the field type, if it is required or optional, if it has a default value, and which validation rules should be applied.

For example, the #Config contains a replicas field defined like this:

#Config: {
    replicas: *1 | int & >0
}

This means that the replicas field defaults to the value of 1, and when specified, the user-supplied value must be an integer greater than zero.

The replicas value is used in the deployment.cue template to set the spec.replicas field of the Kubernetes Deployment:

#Deployment: appsv1.#Deployment & {
    #config: #Config
    spec: {
        replicas: #config.replicas
    }
}

Instance definition

The config.cue file contains a definition called #Instance that is used to specify the list of Kubernetes objects that will be generated from templates when building and applying a module instance.

For example, the #Instance definition takes as input a #Config object and returns the list of objects with the #Deployment and #Service types:

#Instance: {
    config: #Config

    objects: {
        deploy: #Deployment & {#config: config}
        service: #Service & {#config: config}
    }
}

The #Instance definition is used in by the Timoni entry point, defined in timoni.cue file from the root directory, which injects the instance name, namespace and user-supplied config values and then applies the generated objects on the cluster.

timoni: {
    instance: templates.#Instance & {
        config: values
        config: metadata: {
            name:      string @tag(name)
            namespace: string @tag(namespace)
        }
    }

    apply: app: [for obj in instance.objects {obj}]
}

Kubernetes definitions

The deployment.cue and service.cue files contain the CUE definitions used to generate the Deployment and Service Kubernetes objects.

Let's take a look at the service.cue file:

package templates

import (
    corev1 "k8s.io/api/core/v1"
)

#Service: corev1.#Service & {
    #config:    #Config
    apiVersion: "v1"
    kind:       "Service"
    metadata:   #config.metadata
    if #config.service.annotations != _|_ {
        metadata: annotations: #config.service.annotations
    }
    spec: corev1.#ServiceSpec & {
        selector: #config.selector.labels
        ports: [
            {
                port:       #config.service.port
                protocol:   "TCP"
                name:       "http"
                targetPort: name
            },
        ]
    }
}

The package directive specifies the CUE package name, the package name should match the directory name.

The import directive is used to import packages from the cue.mod directory, in this case the k8s.io/api/core/v1 package is imported as corev1. We need this package to generate any Kubernetes object that is part of the v1 API group, like ConfigMap, Secret, ServiceAccount, Service, etc.

With #Service: corev1.#Service & {...} we specify that our #Service definition is a Kubernetes Service object, and that it should inherit all the fields from the corev1.#Service type. This ensures that the generated object will be validated against the Kubernetes API schema.

Inside the #Service definition, we have a #config field of type #Config. The #config field is used as an input parameter for the user-supplied values.

The rest of the #Service definition is used to set the Kubernetes object fields to the #config values.

Optional config fields, like the service.annotations, should be set only if the user supplied a value for them. To verify if a field has a value, we can use an if statement and map the config field inside:

if #config.service.annotations != _|_ {
    metadata: annotations: #config.service.annotations
}

Besides mapping the config fields to the Kubernetes object fields, we can also set fixed values, like the port protocol and name fields.

Extend the module config

Assuming that you want to allow users to expose the NGINX service outside the cluster as a NodePort or LoadBalancer type. To do this, you can add a field called type to the service section of the #Config definition, then map the type value to the Kubernetes Service spec.type field.

Add the config field

Open the config.cue file and add the type field to the service section:

#Config: {
    service: {
        type: *"ClusterIP" | "NodePort" | "LoadBalancer"
    }
}

With the * operator we specify that the type field has a default value of ClusterIP.

With the | operator we enumerate the allowed values for the type field.

Documentation

Note that you should document newly added fields in the module's README.md file. The readme contains a table with the module configuration fields, their type, default value and description.

Map the field in the template

Open the service.cue file and set the spec.type field to the #config.service.type value:

#Service: corev1.#Service & {
    #config: #Config

    spec: {
        type: #config.service.type
    }
}

Test the config

To test the new config field, we can build the module with the default values, and check the generated Service:

timoni build nginx . | grep ClusterIP

To test if we can change the Service type, we can use the debug_values.cue file:

values: {
    service: type: "NodePort"
}

And build the module with the debug values:

timoni build nginx . -f debug_values.cue | grep NodePort

Finally, we can test the validation rules by setting an invalid value, e.g. service: type: "foo".

Running the build or vet command with the debug values should print a validation error:

timoni mod vet --debug