Languages in YAML Costumes
A few years ago, as I started my DevOps/infrastructure engineering journey, I had to learn all the tooling available in the ecosystem. At first, I felt joy writing Helm Charts, Terraform modules, Ansible playbooks. But as time went on, I found myself being more and more frustrated with minor annoyances in these tools.
A code smell is the feeling that something is wrong when reading a piece of code. It may hint at a deeper problem in how a line, a function is written, or even the architecture of the code.
The more I used these tools, the more I felt that something was wrong with them. Here is what most Helm charts look like now:
{{- if .Values.ingress.enabled }}
rules:
- host: {{ .Values.ingress.host | quote }}
http:
paths:
{{- range .Values.ingress.paths }}
- path: {{ .path }}
pathType: {{ .pathType | default "Prefix" }}
{{- end }}
{{- end }}
The templating takes more space than the code. Something is wrong.
Another smell is that lots of Helm charts use indent/nindent. Looking back, I am quite puzzled at our choice to template YAML, which is structured data, with a text templating engine. It seems to be nothing more than a quick fix.
Terraform also smells. If you've ever used a little bit Terraform, I'm sure you used something like count = var.enabled ? 1 : 0 to conditionnally create a resource. I could add that Terraform's merge function is a shallow merge, and there's no way to do a native deep merge.
All of these tools rely on some kind of Domain Specific Language. For Helm, it's the Go template engine with a minimal standard library. For Terraform, it's HCL. And I would argue that tools like Ansible, Hieradata and Kustomize implement DSL-like traits. For example, Ansible uses the keywords when, includes and block to reimplement control logic. These are all languages in YAML costumes!
But all these DSL are intentionnally limited. The standard library they allow is fixed. And I feel constrained using them. What if we used a real programming language instead?
What a real language makes possible
Here's an example of what the configuration of several Kubernetes resources could look like, written in Scheme. Ignore the parentheses, the point is what real computation capabilities brings:
(with-namespace "loulou"
(app #:name "loulou" #:image "secure.io/loulou:2.1" #:port 9000 #:replicas 2
#:env-from '((configMap "loulou-config"))
#:volumes '((secret "loulou-secret" "/etc/loulou/secret"))
#:resources "200m-*/256Mi")
(configmap #:name "loulou-config"
#:data '((LOG_LEVEL . "debug") (CACHE_SIZE . "256")))
(secret #:name "loulou-secret"
#:data '((DB_PASSWORD . "bG91bG91LXNlY3JldA=="))))
(checksum-config)
(compliance-all "secure.io")
In the example, app is a function returning a Deployment and a Service. In Terraform, you would have to build a new module to do this abstraction, but here, it comes nearly free.
The Deployment mounts a Secret at /etc/loulou/secret. No need to manually add references to the Secret to either the volumes or the volumeMounts sections of the manifest. Once was enough!
Writing resource definition is annoying. As a proof of concept, the string "200m-*/256Mi" is a resource spec parsed by code into requests and limits. It manages defaults and best practices, like "memory request" = "memory limit".
Apply systemic changes to all the resources
To be honest, the strongest part of this example is this line:
(checksum-config)
Every time I write a Helm chart, I manually add an annotation with the checksum of all the Configmap and Secrets it depends on. This ensures that the Pods of the Deployment are restarted whenever the Secrets or Configmap are modified.
With this line, instead of having to add the checksum computation manually for each Deployment by passing explicitly the Secrets and Configmap it depends on, all workload resources are automatically annotated!
After every resource is built, the function checksum-config walks the whole set, finds each Deployment, resolves the ConfigMaps and Secrets that Deployment mounts, hashes their contents, and writes the digest onto the pod template as an annotation. Change a ConfigMap and the annotation changes and the pods restart. As a bonus, the function also tracks references to ConfigMap that doesn't exist, and reports its findings, so you can detect inconsistencies early.
Compliance for free
Using code, it's also very easy to check compliance rules. For example you can ensure that:
- every workload sets requests
- there's no privileged containers
- images only comes from a specific registry
Best practices
Here are some best practices I found while using real programming languages:
- Always review the rendered diff, and not just the configuration code. If there's a bug in the code, it can become a systemic bug and affect all your resources. Let's say the parsing of the resource-spec string I mentioned earlier fails and inverts cpu and memory requests. This could be dramatic. Reviewing the rendered diff helps prevent these issues.
- Write unit tests that control the behavior of the additional functions you write, like you would for any programm.
- Do not rely solely on the compliance rules executed at render time. You must continue to check what is actually deployed in your clusters too.
- Make sure your configuration code is deterministic. You now have freedom, you need to use it carefully.
Presenting Hexol
Hexol is a rendering engine written in Guile Scheme. I chose Guile because S-expressions are very similar to the datastructure of Yaml. This makes the code very readable. The macros system available in the language is an added bonus.
A Hexol inventory is a Guile file that returns a list of Operations. Each Operation is a function that takes a State as input, manipulates it, and returns the new state. By chaining all the operations (or "folding" in functional programming), the program actually generates the inventory.
That's it. This simple principle allows you to do anything. Adding a Kubernetes resource is an operation. Patching a Kubernetes resource is another one.
The core of the library provides Operations for basic tasks (deep-merge, delete, copy, move, etc), and the rest of the library provides helper functions for a few specific types of inventories (Terraform, Kubernetes, Ansible, Ledger and Database Schemas).
This blog post is not about Hexol, so I'll leave it at that. If you're interested, feel free to check the project on Github.
If you struggle with complex inventories, many manifests and fight tools like Helm and Terraform, please consider using real programming languages to generate your inventories.