Exploring KCL: Configuration and Data Structure Language; CUE and Pkl Replacement?

I’m in pain… and it’s self-inflicted… and I like it.

I tend to go through an endless number of tools, services, and format in search for better ways to do my job. I’m never satisfied. I always think that there is something better out there. So I go through pain of learning a new tool or a language only to jump into a new one shortly afterwards. Spending endless hours going through new stuff does not make sense, but I can’t help myself. Hopefully, I might save you from doing it yourself. That’s my goal. Go through the pain of trying out everything so that you don’t have to.

Latelly, I’ve been exploring different ways to manage manifests, mostly for Kubernetes resources, but, in reality, for anything that can be defined as data, meaning YAML, JSON, XML, and so on.

Today I want to explore yet another language designed to work with data structures and that language is… KCL. It is a constraint-based record and functional language and, as I’m pronouncing it, I realize that such a description might not make sense to most of use, so here’s a simplified version.

KCL is a language designed to work with data with the goal to produce YAMl or JSON. From that perspective it is similar to Helm, Kustomize, Jsonnet, Carvel ytt, Pkl, and, my current favorites, CUE.

Now, you might be asking “why do we need another one of those?”, and that’s what I’ve been asking myself and others as well. Nevertheless, I’m here to explore it and see if it makes sense to use it.

Now, before we proceed, let me tell you what I’m looking for.

I want to use a language or a DSL that is designed for data structures. The reason for that is that YAML or JSON I might be producing is data. That requirement alone discards Helm since Helm does not understand data. Helm is a free-text templating engine that has no notion of data. It is based on Go templating that is equally bad at genering HTML pages as generating data. Nevertheless, Helm is the de-facto standard for third-party apps so I have to use it for that, but not for my apps.

Then there is Kustomize that is my favorite when simple scenarios are concerned. It’s not a language but, rather, a mechanism that allows us to overlay YAML files with other YAML files. It is simple and effective, but it fails miserably for anything but very simple scenarios. When things are done right, scenarios are simple so I use it heavily, yet, there are cases when I need more.

Then there is Jsonnet, Carvel ytt, and a myriad of other solutions which, for one reason or another, I gave up on. Currently, I’m torn between CUE with Timoni and Pkl. I explored all those so I won’t go into them today. Check them out if you’re not familiar with them.

Both of those are doing just what I need them to do, and that’s where mazochism kicks in. I don’t need another one, yet I’m exploring KCL. There are a few reasons for doing that besides enjoying self-inflicted pain.

Going back to my requirements…

Besides the need for a language or a DSL that understands data-structures instead of being general-purpose anything goes, I also need to be able to define schemas but also to import schemas. It would be silly for me to reinvent the wheel by creating schemas for, let’s say, Kubernetes APIs. I expect those to be readily available and, in case of CRDs, to have the option to import them from a cluster or a Git repo.

I also insist on immutability. I experienced too many issues when working with mutable data. I want to avoid that at all costs.

Finally, I don’t want to spend weeks trying to learn it. A day is more than enough. If it takes more than that, excluding special “advanced” features, it’s too complicated for my tiny brain. It needs to be easy.

So, I’m looking for a data-structure language that comes with pre-built schemas but also allows me to create them myself or import them from somewhere, I need it to be immutable and it cannot be hard to learn.

With that in mind, let’s take a look at KCL and see whether it fits those requirements and whether it might convince me to drop at CUE or Pkl.

Let’s see what KCL is all about.

Setup

Install kcl CLI https://kcl-lang.io/docs/user_docs/getting-started/install#1-install-kcl

Install kcl Language Server and IDE extension from https://kcl-lang.io/docs/user_docs/getting-started/install#2-install-kcl-ide-extension.

git clone https://github.com/vfarcic/crossplane-app

cd crossplane-app

git pull

git checkout kcl

KCL in Action

Before we begin, I must say that KCL is a CNCF project meaning that it is not in the hands of a single company making it’s future depend less on the whims of that company. It generated a lot of buzz and it has a very active community. There must be something in there, right?

Right now, I’m inside a project that requires a rather large amount of YAML. That YAML is big enough and with enough complexity and repetition that it makes no sense to write it directly. I need to generate it somehow, and I was about to switch to CUE when I got introduced to KCL so I though “What the heck. Let’s give it a try.”

As you can expect, I can execute kcl with the path to the code, hit the enter key, and…

kcl kcl/backend.k

The output is as follows.

apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
  name: app-backend
  labels:
    type: backend
    location: local
spec:
  compositeTypeRef:
    apiVersion: devopstoolkitseries.com/v1alpha1
    kind: App
  patchSets:
  - name: metadata
    patches:
    - fromFieldPath: metadata.labels
  resources:
  - name: kubernetes
    base:
      apiVersion: kubernetes.crossplane.io/v1alpha1
      kind: ProviderConfig
      spec:
        credentials:
          source: InjectedIdentity
    patches:
    - fromFieldPath: spec.id
      toFieldPath: metadata.name
    readinessChecks:
    - type: None
  - name: deployment
    base:
      apiVersion: kubernetes.crossplane.io/v1alpha1
      kind: Object
      spec:
        forProvider:
          manifest:
            apiVersion: apps/v1
            kind: Deployment
            spec:
              selector: {}
              template:
                spec:
                  containers:
                  - livenessProbe:
                      httpGet:
                        path: /
                        port: 80
                    name: backend
                    ports:
                    - containerPort: 80
                    readinessProbe:
                      httpGet:
                        path: /
                        port: 80
                    resources:
                      limits:
                        cpu: 250m
                        memory: 256Mi
                      requests:
                        cpu: 125m
                        memory: 128Mi
    patches:
    - fromFieldPath: spec.id
      toFieldPath: metadata.name
      transforms:
      - type: string
        string:
          fmt: '%s-deployment'
    - fromFieldPath: spec.id
      toFieldPath: spec.forProvider.manifest.metadata.name
    - fromFieldPath: spec.parameters.namespace
      toFieldPath: spec.forProvider.manifest.metadata.namespace
    - fromFieldPath: spec.id
      toFieldPath: spec.forProvider.manifest.metadata.labels.app
    - fromFieldPath: spec.id
      toFieldPath: spec.forProvider.manifest.spec.selector.matchLabels.app
    - fromFieldPath: spec.id
      toFieldPath: spec.forProvider.manifest.spec.template.metadata.labels.app
    - fromFieldPath: spec.parameters.image
      toFieldPath: spec.forProvider.manifest.spec.template.spec.containers[0].image
    - fromFieldPath: spec.parameters.port
      toFieldPath: spec.forProvider.manifest.spec.template.spec.containers[0].ports[0].containerPort
    - fromFieldPath: spec.parameters.port
      toFieldPath: spec.forProvider.manifest.spec.template.spec.containers[0].livenessProbe.httpGet.port
    - fromFieldPath: spec.parameters.port
      toFieldPath: spec.forProvider.manifest.spec.template.spec.containers[0].readinessProbe.httpGet.port
    - fromFieldPath: spec.id
      toFieldPath: spec.providerConfigRef.name
  - name: service
    base:
      apiVersion: kubernetes.crossplane.io/v1alpha1
      kind: Object
      spec:
        forProvider:
          manifest:
            apiVersion: v1
            kind: Service
            spec:
              ports:
              - name: http
                port: 8008
                protocol: TCP
              type: ClusterIP
    patches:
    - fromFieldPath: spec.id
      toFieldPath: metadata.name
      transforms:
      - type: string
        string:
          fmt: '%s-service'
    - fromFieldPath: spec.id
      toFieldPath: spec.forProvider.manifest.metadata.name
    - fromFieldPath: spec.parameters.namespace
      toFieldPath: spec.forProvider.manifest.metadata.namespace
    - fromFieldPath: spec.id
      toFieldPath: spec.forProvider.manifest.metadata.labels.app
    - fromFieldPath: spec.id
      toFieldPath: spec.forProvider.manifest.spec.selector.app
    - fromFieldPath: spec.parameters.port
      toFieldPath: spec.forProvider.manifest.spec.ports[0].port
    - fromFieldPath: spec.parameters.port
      toFieldPath: spec.forProvider.manifest.spec.ports[0].targetPort
    - fromFieldPath: spec.id
      toFieldPath: spec.providerConfigRef.name
  - name: ingress
    base:
      apiVersion: kubernetes.crossplane.io/v1alpha1
      kind: Object
      spec:
        forProvider:
          manifest:
            apiVersion: networking.k8s.io/v1
            kind: Ingress
            metadata:
              annotations:
                ingress.kubernetes.io/ssl-redirect: 'false'
            spec:
              rules:
              - http:
                  paths:
                  - backend:
                      service:
                        name: acme
                    path: /
                    pathType: ImplementationSpecific
    patches:
    - fromFieldPath: spec.id
      toFieldPath: metadata.name
      transforms:
      - type: string
        string:
          fmt: '%s-ingress'
    - fromFieldPath: spec.id
      toFieldPath: spec.forProvider.manifest.metadata.name
    - fromFieldPath: spec.parameters.namespace
      toFieldPath: spec.forProvider.manifest.metadata.namespace
    - fromFieldPath: spec.id
      toFieldPath: spec.forProvider.manifest.metadata.labels.app
    - fromFieldPath: spec.parameters.host
      toFieldPath: spec.forProvider.manifest.spec.rules[0].host
    - fromFieldPath: spec.id
      toFieldPath: spec.forProvider.manifest.spec.rules[0].http.paths[0].backend.service.name
    - fromFieldPath: spec.parameters.port
      toFieldPath: spec.forProvider.manifest.spec.rules[0].http.paths[0].backend.service.port.number
    - fromFieldPath: spec.id
      toFieldPath: spec.providerConfigRef.name
    - type: ToCompositeFieldPath
      fromFieldPath: spec.forProvider.manifest.spec.rules[0].host
      toFieldPath: status.host

There we go.

The output is over 150 lines of YAML.

Now that you saw the monstruosity that was generated, let’s take a look at the KCL definition that made that possible.

cat kcl/backend.k

The output is as follows.

import .common
import .deployment
import .service
import .ingress
import .kubernetesProviderConfig

common.Composition {
    metadata = common.Metadata {
        name = "app-backend"
        labels = common.Labels {
            type = "backend"
            location = "local"
        }
    }
    spec = common.Spec {
        resources = [
            kubernetesProviderConfig.KubernetesProviderConfig {}
            deployment.Deployment {}
            service.Service {}
            ingress.Ingress {}
        ]
    }
}

At the very top, I am importing schemas, functions, global variables, and a few other things. There’s common that contains, as you might expect from the name, common stuff that I could not place in a specific “box”. Then there are deployment, service, and ingress which are my abstractions that match corresponding Kubernetes resources, even though this YAML is generating Crossplane Compositions and not Kubernetes resources directly. Finally, there’s kubernetesProviderConfig that is a Crossplane ProviderConfig.

Such an organization allows me to avoid repetition since, as you will see soon, those imports are repeated across other KCL manifests.

The actual output is generated by invoking Composition schema defined in common, we’ll see it soon. That schema already contains all the values that are the same for all variations as well as variables that need to be defined explicitly. In this case, I have to define only the name, the labels, and the resources array that, in this case, contains deployment, service, and ingress schemas also imported at the top.

Here’s a variation of that manifest that generates a similar output but with a few differences.

cat kcl/backend-db-remote.k

The output is as follows.

import .common
import .deployment
import .service
import .ingress

common.Composition {
    metadata = common.Metadata {
        name = "app-backend-db-remote"
        labels = common.Labels {
            type = "backend-db"
            location = "remote"
        }
    }
    spec = common.Spec {
        resources = [
            deployment.Deployment{
                _dbEnabled = True
                _dbSecretName = "spec.parameters.dbSecret.name"
                _providerConfigName = "spec.parameters.kubernetesProviderConfigName"
            },
            service.Service{
                _providerConfigName = "spec.parameters.kubernetesProviderConfigName"
            },
            ingress.Ingress{
                _providerConfigName = "spec.parameters.kubernetesProviderConfigName"
            },
        ]
    }
}

This KCL definition will generate a different Composition. Besides a different name and labels, customized versions of the Deployment, Service, and Ingress by passing variables to their respective schemas. We’ll see those soon. For now, let’s take a look at the output of that KCL definition.

kcl kcl/backend-db-remote.k

The output is as follows.

apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
  name: app-backend-db-remote
  labels:
    type: backend-db
    location: remote
spec:
  compositeTypeRef:
    apiVersion: devopstoolkitseries.com/v1alpha1
    kind: App
  patchSets:
  - name: metadata
    patches:
    - fromFieldPath: metadata.labels
  resources:
  - name: deployment
    base:
      apiVersion: kubernetes.crossplane.io/v1alpha1
      kind: Object
      spec:
        forProvider:
          manifest:
            apiVersion: apps/v1
            kind: Deployment
            spec:
              selector: {}
              template:
                spec:
                  containers:
                  - env:
                    - name: DB_ENDPOINT
                      valueFrom:
                        secretKeyRef:
                          key: endpoint
                    - name: DB_PASSWORD
                      valueFrom:
                        secretKeyRef:
                          key: password
                    - name: DB_PORT
                      valueFrom:
                        secretKeyRef:
                          key: port
                          optional: true
                    - name: DB_USERNAME
                      valueFrom:
                        secretKeyRef:
                          key: username
                    - name: DB_NAME
                    livenessProbe:
                      httpGet:
                        path: /
                        port: 80
                    name: backend
                    ports:
                    - containerPort: 80
                    readinessProbe:
                      httpGet:
                        path: /
                        port: 80
                    resources:
                      limits:
                        cpu: 250m
                        memory: 256Mi
                      requests:
                        cpu: 125m
                        memory: 128Mi
    patches:
    - fromFieldPath: spec.id
      toFieldPath: metadata.name
      transforms:
      - type: string
        string:
          fmt: '%s-deployment'
    - fromFieldPath: spec.id
      toFieldPath: spec.forProvider.manifest.metadata.name
    - fromFieldPath: spec.parameters.namespace
      toFieldPath: spec.forProvider.manifest.metadata.namespace
    - fromFieldPath: spec.id
      toFieldPath: spec.forProvider.manifest.metadata.labels.app
    - fromFieldPath: spec.id
      toFieldPath: spec.forProvider.manifest.spec.selector.matchLabels.app
    - fromFieldPath: spec.id
      toFieldPath: spec.forProvider.manifest.spec.template.metadata.labels.app
    - fromFieldPath: spec.parameters.image
      toFieldPath: spec.forProvider.manifest.spec.template.spec.containers[0].image
    - fromFieldPath: spec.parameters.port
      toFieldPath: spec.forProvider.manifest.spec.template.spec.containers[0].ports[0].containerPort
    - fromFieldPath: spec.parameters.port
      toFieldPath: spec.forProvider.manifest.spec.template.spec.containers[0].livenessProbe.httpGet.port
    - fromFieldPath: spec.parameters.port
      toFieldPath: spec.forProvider.manifest.spec.template.spec.containers[0].readinessProbe.httpGet.port
    - fromFieldPath: spec.parameters.dbSecret.name
      toFieldPath: spec.forProvider.manifest.spec.template.spec.containers[0].env[0].valueFrom.secretKeyRef.name
    - fromFieldPath: spec.parameters.dbSecret.name
      toFieldPath: spec.forProvider.manifest.spec.template.spec.containers[0].env[1].valueFrom.secretKeyRef.name
    - fromFieldPath: spec.parameters.dbSecret.name
      toFieldPath: spec.forProvider.manifest.spec.template.spec.containers[0].env[2].valueFrom.secretKeyRef.name
    - fromFieldPath: spec.parameters.dbSecret.name
      toFieldPath: spec.forProvider.manifest.spec.template.spec.containers[0].env[3].valueFrom.secretKeyRef.name
    - fromFieldPath: spec.parameters.dbSecret.name
      toFieldPath: spec.forProvider.manifest.spec.template.spec.containers[0].env[4].value
    - fromFieldPath: spec.parameters.kubernetesProviderConfigName
      toFieldPath: spec.providerConfigRef.name
  - name: service
    base:
      apiVersion: kubernetes.crossplane.io/v1alpha1
      kind: Object
      spec:
        forProvider:
          manifest:
            apiVersion: v1
            kind: Service
            spec:
              ports:
              - name: http
                port: 8008
                protocol: TCP
              type: ClusterIP
    patches:
    - fromFieldPath: spec.id
      toFieldPath: metadata.name
      transforms:
      - type: string
        string:
          fmt: '%s-service'
    - fromFieldPath: spec.id
      toFieldPath: spec.forProvider.manifest.metadata.name
    - fromFieldPath: spec.parameters.namespace
      toFieldPath: spec.forProvider.manifest.metadata.namespace
    - fromFieldPath: spec.id
      toFieldPath: spec.forProvider.manifest.metadata.labels.app
    - fromFieldPath: spec.id
      toFieldPath: spec.forProvider.manifest.spec.selector.app
    - fromFieldPath: spec.parameters.port
      toFieldPath: spec.forProvider.manifest.spec.ports[0].port
    - fromFieldPath: spec.parameters.port
      toFieldPath: spec.forProvider.manifest.spec.ports[0].targetPort
    - fromFieldPath: spec.parameters.kubernetesProviderConfigName
      toFieldPath: spec.providerConfigRef.name
  - name: ingress
    base:
      apiVersion: kubernetes.crossplane.io/v1alpha1
      kind: Object
      spec:
        forProvider:
          manifest:
            apiVersion: networking.k8s.io/v1
            kind: Ingress
            metadata:
              annotations:
                ingress.kubernetes.io/ssl-redirect: 'false'
            spec:
              rules:
              - http:
                  paths:
                  - backend:
                      service:
                        name: acme
                    path: /
                    pathType: ImplementationSpecific
    patches:
    - fromFieldPath: spec.id
      toFieldPath: metadata.name
      transforms:
      - type: string
        string:
          fmt: '%s-ingress'
    - fromFieldPath: spec.id
      toFieldPath: spec.forProvider.manifest.metadata.name
    - fromFieldPath: spec.parameters.namespace
      toFieldPath: spec.forProvider.manifest.metadata.namespace
    - fromFieldPath: spec.id
      toFieldPath: spec.forProvider.manifest.metadata.labels.app
    - fromFieldPath: spec.parameters.host
      toFieldPath: spec.forProvider.manifest.spec.rules[0].host
    - fromFieldPath: spec.id
      toFieldPath: spec.forProvider.manifest.spec.rules[0].http.paths[0].backend.service.name
    - fromFieldPath: spec.parameters.port
      toFieldPath: spec.forProvider.manifest.spec.rules[0].http.paths[0].backend.service.port.number
    - fromFieldPath: spec.parameters.kubernetesProviderConfigName
      toFieldPath: spec.providerConfigRef.name
    - type: ToCompositeFieldPath
      fromFieldPath: spec.forProvider.manifest.spec.rules[0].host
      toFieldPath: status.host

That’s a longer one with around 200 lines of YAML.

Let’s shift focus on the imports. One of those was common.

cat kcl/common.k

The output is as follows.

schema Composition:
    apiVersion = "apiextensions.crossplane.io/v1"
    kind = "Composition"
    metadata: Metadata
    spec: Spec

schema Metadata:
    name: str
    labels: Labels

schema Spec:
    compositeTypeRef = {
        apiVersion = "devopstoolkitseries.com/v1alpha1"
        kind = "App"
    }
    patchSets = [{
        name = "metadata"
        patches = [{fromFieldPath = "metadata.labels"}]
    }]
    resources: []

schema Labels:
    type: str
    location: str

schema KubernetesObject:
    name: str
    base = {
        apiVersion = "kubernetes.crossplane.io/v1alpha1"
        kind = "Object"
        spec: KubernetesObjectSpec
    }
    patches: []

schema KubernetesObjectBase:
    apiVersion = "kubernetes.crossplane.io/v1alpha1"
    kind = "Object"
    spec: KubernetesObjectSpec

schema KubernetesObjectSpec:
    forProvider: KubernetesObjectForProvider

schema KubernetesObjectForProvider:
    manifest: any

Patches = lambda name: str -> [] {
    [
        {
            fromFieldPath = "spec.id"
            toFieldPath = "metadata.name"
            transforms = [{type = "string", string = { fmt = "%s-{}".format(name)}}]
        },
        {fromFieldPath = "spec.id", toFieldPath = "spec.forProvider.manifest.metadata.name"},
        {fromFieldPath = "spec.parameters.namespace", toFieldPath = "spec.forProvider.manifest.metadata.namespace"},
        {fromFieldPath = "spec.id", toFieldPath = "spec.forProvider.manifest.metadata.labels.app"},
    ]
}

ManifestSpec = "spec.forProvider.manifest.spec"

Most of that file contains a schema I’m using to generate output I need. There is Composition with a few hard-coded values like apiVersion and kind, and a few sub-schemas like Metadata and Spec. Metadata, on the other hand, defines a str variable name. That’s the name we saw at the very beginning. Whoever is using that schema has to define it or KCL will throw an error.

That continues on and on with different schemas containing either fields that are hard-coded or variables that need to be defined by whoever is using them.

Now, the syntax itself is pretty much Json with key values being separate by = and values and types being separated by :. Objects are defined with curly braces {} and arrays with square brackets []. There are a few more things that are different from Json but, in general, if you know Json or CUE, you should be able to pick it up quickly. If you’re not familiar with either of those, you’ll still be able to learn it relatively fast. KCL is one of the most powerful yet one of the easiest data-structure languages I used. We’ll talk about that later.

We can also use functions like, in this case, Patches. In KCL, functions are called lambda and this one defines a single argument name that is a str and returns an array ([, ]) of objects ({).

Finally, that definition defines a variable ManifestSpec that I’m using as a way to avoid typing that path.

There’s more though. Much much more than we can explore in this video, so I’ll focus on only a few other aspects of KCL which we can see in the deployment.k file which is one of the imports we saw at the start.

cat kcl/deployment.k

The output is as follows.

import .common
import k8s.api.apps.v1 as k8sapps

schema Deployment(common.KubernetesObject):
    _dbEnabled: bool = False
    _dbSecretName: str = "spec.id"
    _providerConfigName: str = "spec.id"
    _container = "{}.template.spec.containers[0]".format(common.ManifestSpec)
    name = "deployment"
    base = common.KubernetesObjectBase{
        spec.forProvider.manifest = k8sapps.Deployment{
            spec = {
                selector = {}
                template = {
                    spec = {
                        containers = [{
                            name = "backend"
                            ports = [{containerPort = 80 }]
                            livenessProbe = {httpGet = {path = "/", port = 80 }}
                            readinessProbe = {httpGet = {path = "/", port = 80 }}
                            resources = {
                                limits = {cpu = "250m", memory = "256Mi" }
                                requests = {cpu = "125m", memory = "128Mi" }
                            }
                            if _dbEnabled:
                                env = [
                                    {name = "DB_ENDPOINT", valueFrom.secretKeyRef.key = "endpoint" },
                                    {name = "DB_PASSWORD", valueFrom.secretKeyRef.key = "password" },
                                    {name = "DB_PORT", valueFrom.secretKeyRef = {key = "port",  optional = True }},
                                    {name = "DB_USERNAME",  valueFrom.secretKeyRef.key = "username" },
                                    {name = "DB_NAME" },
                                ]
                        }]
                    }
                }
            }
        }
    }
    patches = common.Patches("deployment") + [
        {
            fromFieldPath = "spec.id",
            toFieldPath = "{}.selector.matchLabels.app".format(common.ManifestSpec)
        }, {
            fromFieldPath = "spec.id",
            toFieldPath = "{}.template.metadata.labels.app".format(common.ManifestSpec)
        }, {
            fromFieldPath = "spec.parameters.image",
            toFieldPath = "{}.image".format(_container)
        }, {
            fromFieldPath = "spec.parameters.port",
            toFieldPath = "{}.ports[0].containerPort".format(_container)
        }, {
            fromFieldPath = "spec.parameters.port",
            toFieldPath = "{}.livenessProbe.httpGet.port".format(_container)
        }, {
            fromFieldPath = "spec.parameters.port",
            toFieldPath = "{}.readinessProbe.httpGet.port".format(_container)
        },
        if _dbEnabled:
            {
                fromFieldPath = _dbSecretName,
                toFieldPath = "{}.env[0].valueFrom.secretKeyRef.name".format(_container)
            }, {
                fromFieldPath = _dbSecretName,
                toFieldPath = "{}.env[1].valueFrom.secretKeyRef.name".format(_container)
            }, {
                fromFieldPath = _dbSecretName,
                toFieldPath = "{}.env[2].valueFrom.secretKeyRef.name".format(_container)
            },
            {
                fromFieldPath = _dbSecretName,
                toFieldPath = "{}.env[3].valueFrom.secretKeyRef.name".format(_container)
            }, {
                fromFieldPath = _dbSecretName,
                toFieldPath = "{}.env[4].value".format(_container)
            },
        {
            fromFieldPath = _providerConfigName,
            toFieldPath = "spec.providerConfigRef.name"
        },
    ]

Look at that import. KCL comes with pre-built schemas for all core Kubernetes APIs, and many others. In this case, we’re importing k8s.api.apps.v1 and using it to define k8sapps.Deployment which, surprise surprise, is a Kubernetes Deployment.

Don’t be confused that the Deployment is defined inside spec.forProvider.manifest. That’s a part of a Crossplane Composition which I’m not exploring in this video but only using as an example of what KCL can do.

The Deployment schema I’m defining here is inheriting from the KubernetesObject schema I defined in the common. That means that it inherits everything from that one and I can define only the things that are different.

Inside that schema, I defined a few variables with names prefixed with _. Those are not exported and, as such, are mutable. That’s a great feature of KCL. Exported variables are immutable, non-exported are not. That might be one of the things I like the most about KCL. It’s immutable when exported data is concerned, but it still allows us to mutate non-exported data. If you’re not familiar with terms “exported” and “non-exported”, think of them as “public” and “private” in other languages. Only exported data is output to YAML or JSON.

We can see, for example, that _dbEnabled is a bool variable with the default value of False. The rest follows the similar pattern.

The last non-exported variable shows the usage of the format function that will replace open-closed curly braces ({}) with the value of common.ManifestSpec.

What else…

The patches value is an interesting one. it showcases unioning of collections. Over there I’m saying that the value should be a combination of a list defined in common Patches("deployment") and whatever is defined below. There are some common entries that should be defined in all patches and by including items from that collection with whatever is defined below, I’m avoiding repetition.

Patches itself, if you remember, is a function that returns an array of objects. That’s the one we saw when we explore common.k file.

The last thing I want to show is the ability to define expressions. In this case, it is a simple if conditional that will include a few more entries into the list if _dbEnabled is True.

We saw only a fraction of what KCL can so, but I think that was enogh syntax for today. KCL is massive and you should spend a bit of time going though the docs yourself.

Outside the syntax itself, there are a couple of other things I feel are worth mentioning.

The CLI itself is not overwhelming, yet it does have a few important features outside of the obvious one that allows us to generate YAML or JSON from KCL definitions.

kcl --help

The output is as follows.

The KCL Command Line Interface (CLI).

KCL is an open-source, constraint-based record and functional language that
enhances the writing of complex configurations, including those for cloud-native
scenarios. The KCL website: https://kcl-lang.io

Usage:
  kcl [command]

Available Commands:
  clean       KCL clean tool
  completion  Generate the autocompletion script for the specified shell
  doc         KCL document tool
  fmt         KCL format tool
  help        Help about any command
  import      KCL import tool
  lint        Lint KCL codes.
  mod         KCL module management
  play        Open the kcl playground in the browser.
  registry    KCL registry management
  run         Run KCL codes.
  server      Run a KCL server
  test        KCL test tool
  version     Show version of the KCL CLI
  vet         KCL validation tool

Flags:
  -h, --help      help for kcl
  -v, --version   version for kcl

Use "kcl [command] --help" for more information about a command.

We can, for example, use import to convert existing JSON, YAML, Go structs, Terraform, OpenAPI, Kubernetes CRDs, and a few other formats into KCL. That’s great as a starting point.

We can use mod to initiate a KCL project, add dependencies like those we saw earlier when I showed Kubernetes schemas, to package KCL, or to pull it or push it into a registry.

There is the option to play with KCL by spining up a local server that will allow us to interact with KCL in a browser.

And so on and so forth.

IDE support is supperb. I’m using VS Code and the KCL extension is great. It provides syntax highlighting, auto-complete, go-to-definition, and a few other features that make working with KCL a breeze.

There is also integration and support for a bunch of other tools like Argo CD and Flux, CI pipelines, Hashi Vault, Terraform, and a few others.

Okay. That’s it as far as walkthrough it concerned. Let’s talk about pros and cons.

KCL Pros and Cons

As a reminder, my requirements are to have a language or a DSL that is focused on data-structures, the ability to work with schemas, immutability, and for it to be easy to learn.

KCL meets all those and so much more.

As a matter of fact, I could not find a single thing I don’t like except that I was initially confused with the documentation but that was my fault. I was too hasty.

If I would have to nitpick, I’d say that the only potential, but minor, issue is that the docs contain quite a few spelling errors in the English version. I can only guess that Chinese version is better but, for obvious reasons, I cannot confirm that. Nevertheless, that’s a minor issue that does not affect the quality of the docs.

That’s it. That’s the only negative thing I could find. KCL is awesome, and here are only a few out of many reasons why I think so.

Documentation is amazing, even though I had initial trouble understanding how it is organized. It is clear that a lot of attention is put into the design of the language and documenting every detail that mateers. I disliked it at first because of the docs but now that I went through most of it, I can safely say that was my fault. The docs are great.

Having all exported data immutable is just what I think we need and that’s what KCL provides. The addition of mutable non-exported data is a great feature.

It’s (relatively) easy to learn. You’ll be up-and-running in no time, a day at most. You will not know everything KCL offers, that takes time, but you’ll be able to do most things in a day.

What else… It’s powerful. I did not find anything missing. Everything I needed so far is there, and I know that there’s so much more so it my needs change in the future, I’m confident that KCL will be able to accommodate them.

Next, the VS Code plugin is great. Syntax highlighting, auto-complete, goto definitions, and all other features we might expect are there. It’s a pleasure to work with KCL in VS Code.

Finally, KCL is a project donated to Cloud Native Computing Foundation (CNCF). That makes it less prone to future license changes and makes it more likely to have a vibrant and diverse community.

Personally, I cannot sit on more than two chairs at the same time. Until now, I was torn between CUE and Pkl. I’ll kick one of those out to gain space for KCL. Pkl, you’re out. KCL is in. I might be biased though. I love CUE and KCL looks very similar to it except that it’s easier to learn. It’s those two now.

Destroy

git checkout main