dhall-kubernetes
contains Dhall bindings to Kubernetes,
so you can generate Kubernetes objects definitions from Dhall expressions.
This will let you easily typecheck, template and modularize your Kubernetes definitions.
Once you build a slightly non-trivial Kubernetes setup, with many objects floating around, you'll encounter several issues:
- Writing the definitions in YAML is really verbose, and the actually important things don't stand out that much
- Ok I have a bunch of objects that'll need to be configured together, how do I share data?
- I'd like to reuse an object for different environments, but I cannot make it parametric..
- In general, I'd really love to reuse parts of some definitions in other definitions
- Oh no, I typoed a key and I had to wait until I pushed to the cluster to get an error back :(
The natural tendency is to reach for a templating language + a programming language to orchestrate that + some more configuration for it... But this is just really messy (been there), and we can do better.
Dhall solves all of this, being a programming language with builtin templating, all while being non-Turing complete, strongly typed and strongly normalizing (i.e.: reduces everything to a normal form, no matter how much abstraction you build), so saving you from the "oh-noes-I-made-my-config-in-code-and-now-its-too-abstract" nightmare.
For a Dhall Tutorial, see the website, or the readme of the project, or the full tutorial.
NOTE: dhall-kubernetes
requires at least version 1.27.0
of the interpreter
(version 11.0.0
of the language).
Let's say we'd like to configure a Deployment exposing an nginx
webserver.
In the following example, we:
- Import the Kubernetes definitions as a Dhall package (the
package.dhall
file) from the local repo. In your case you will want to replace the local path with a remote one, e.g.https://raw.githubusercontent.com/dhall-lang/dhall-kubernetes/master/package.dhall
Note: thesha256:..
is applied to some imports so that:- the import is cached locally after the first evaluation, with great time savings (and avoiding network calls)
- prevent execution if the content of the file changes. This is a security feature, and you
can read more in Dhall's "Security Guarantees" document
Note: instead of using the
package.dhall
from themaster
branch, you may want to use a tagged release, as the contents of themaster
branch are liable to change without warning.
- Define the Deployment using the schema pattern and hardcoding the deployment details:
-- examples/deploymentSimple.dhall
let kubernetes =
https://raw.githubusercontent.com/dhall-lang/dhall-kubernetes/master/package.dhall sha256:532e110f424ea8a9f960a13b2ca54779ddcac5d5aa531f86d82f41f8f18d7ef1
let deployment =
kubernetes.Deployment::{
, metadata = kubernetes.ObjectMeta::{ name = Some "nginx" }
, spec = Some kubernetes.DeploymentSpec::{
, selector = kubernetes.LabelSelector::{
, matchLabels = Some (toMap { name = "nginx" })
}
, replicas = Some +2
, template = kubernetes.PodTemplateSpec::{
, metadata = Some kubernetes.ObjectMeta::{ name = Some "nginx" }
, spec = Some kubernetes.PodSpec::{
, containers =
[ kubernetes.Container::{
, name = "nginx"
, image = Some "nginx:1.15.3"
, ports = Some
[ kubernetes.ContainerPort::{ containerPort = +80 } ]
}
]
}
}
}
}
in deployment
We then run this through dhall-to-yaml
to generate our Kubernetes definition:
dhall-to-yaml <<< ./examples/deploymentSimple.dhall
And we get:
## examples/out/deploymentSimple.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx
spec:
replicas: 2
selector:
matchLabels:
name: nginx
template:
metadata:
name: nginx
spec:
containers:
- image: nginx:1.15.3
name: nginx
ports:
- containerPort: 80
The above is cool, but hardcoding data is not that cool.
So in a more realistic deployment you'll probably want to define:
- some
MyService
type that contains the config settings relevant to your deployments - some functions parametrized by this type, so that you can produce objects to send to k8s
by just applying these functions to
MyService
objects
This is useful because then you can define your Service
s separately from the Kubernetes logic,
and reuse those objects for configuring other things (e.g. configuring the services themselves,
templating documentation, configuring Terraform deployments, you name it).
As an example of that, next we'll define an Ingress (an Nginx Ingress in this case), containing stuff like TLS certs and routes for every service - see the schema.
Things to note in the following example:
- we define the
Service
type inline in the file, but in your case you'll want to have a separate./Service.dhall
file (so you can share around the project) - we define functions to create the TLS definitions and the routes, so that we can
map
them over the list of services. - we also defined the list of
services
inline, but you should instead return themkIngress
function instead of applying it, so you can do something likedhall-to-yaml <<< "./mkIngress.dhall ./myServices.dhall"
-- examples/ingress.dhall
let Prelude =
../Prelude.dhall sha256:10db3c919c25e9046833df897a8ffe2701dc390fa0893d958c3430524be5a43e
let map = Prelude.List.map
let kubernetes =
https://raw.githubusercontent.com/dhall-lang/dhall-kubernetes/master/package.dhall sha256:532e110f424ea8a9f960a13b2ca54779ddcac5d5aa531f86d82f41f8f18d7ef1
let Service = { name : Text, host : Text, version : Text }
let services = [ { name = "foo", host = "foo.example.com", version = "2.3" } ]
let makeTLS
: Service → kubernetes.IngressTLS.Type
= λ(service : Service) →
{ hosts = Some [ service.host ]
, secretName = Some "${service.name}-certificate"
}
let makeRule
: Service → kubernetes.IngressRule.Type
= λ(service : Service) →
{ host = Some service.host
, http = Some
{ paths =
[ { backend =
{ serviceName = service.name
, servicePort = kubernetes.IntOrString.Int +80
}
, path = None Text
}
]
}
}
let mkIngress
: List Service → kubernetes.Ingress.Type
= λ(inputServices : List Service) →
let annotations =
toMap
{ `kubernetes.io/ingress.class` = "nginx"
, `kubernetes.io/ingress.allow-http` = "false"
}
let defaultService =
{ name = "default"
, host = "default.example.com"
, version = " 1.0"
}
let ingressServices = inputServices # [ defaultService ]
let spec =
kubernetes.IngressSpec::{
, tls = Some
( map
Service
kubernetes.IngressTLS.Type
makeTLS
ingressServices
)
, rules = Some
( map
Service
kubernetes.IngressRule.Type
makeRule
ingressServices
)
}
in kubernetes.Ingress::{
, metadata = kubernetes.ObjectMeta::{
, name = Some "nginx"
, annotations = Some annotations
}
, spec = Some spec
}
in mkIngress services
As before we get the yaml out by running:
dhall-to-yaml <<< ./examples/ingress.dhall
Result:
## examples/out/ingress.yaml
apiVersion: networking.k8s.io/v1beta1
kind: Ingress
metadata:
annotations:
kubernetes.io/ingress.allow-http: 'false'
kubernetes.io/ingress.class: nginx
name: nginx
spec:
rules:
- host: foo.example.com
http:
paths:
- backend:
serviceName: foo
servicePort: 80
- host: default.example.com
http:
paths:
- backend:
serviceName: default
servicePort: 80
tls:
- hosts:
- foo.example.com
secretName: foo-certificate
- hosts:
- default.example.com
secretName: default-certificate
It is usual for k8s YAML files to include multiple objects separated by ---
("documents" in YAML lingo),
so you might want to do it too.
If the objects have the same type, this is very easy: you return a Dhall list containing the
objects, and use the --documents
flag, e.g.:
dhall-to-yaml --documents <<< "let a = ./examples/deploymentSimple.dhall in [a, a]"
If the objects are of different type, it's not possible to have separate documents in the same YAML file.
However, since k8s has a builtin List
type for these cases,
it's possible to use it together with the union type of all k8s types that we generate.
So if we want to deploy e.g. a Deployment and a Service together, we can do:
let k8s = ./typesUnion.dhall
in
{ apiVersion = "v1"
, kind = "List"
, items =
[ k8s.Deployment ./my-deployment.dhall
, k8s.Service ./my-service.dhall
]
}
- dhall-prometheus-operator: Provides types and default records for Prometheus Operators.
You will need to install Nix in order to run the file-generation scripts provided by this repository. You can obtain Nix by following the instructions here:
The top-level README.md
is generated from ./docs/README.md.dhall
so that
the examples within the ./examples
directory stay in sync with the
README.md
. That means that in order to update the README.md
you need to
first edit ./docs/README.md.dhall
and then run:
$ ./scripts/generate readme
If you want to author new examples, add them to the ./examples
directory and
run:
$ ./scripts/generate examples
… which will freeze and type-check each example and generate the matching YAML output.
The ./examples
directory is only built against one version of the Kubernetes
API (the "preferred" version). To change the preferred version, run:
$ echo "${VERSION}" > ./nix/preferred.txt
… and then re-run the example generation script:
$ ./scripts/generate examples
To add a new supported Kubernetes release, run:
./scripts/add-kubernetes-release "${VERSION}"
The logic for generating the Dhall code doesn't reside within this
repository but actually resides within the
dhall-openapi
subproject of the dhall-haskell
repository. That means that if you want to change the generated code you will
need to do so in two steps:
-
Make a pull request against the upstream
dhall-haskell
repository to change the code generated bydhall-openapi
-
Make a pull request against this repository to pick up a newer reference to the
dhall-haskell
repository incorporating the change todhall-openapi
If you try to create a pull request to amend the generated Dhall files directly
then CI will reject the pull request since it verifies that the Dhall code
stored in version control matches what dhall-openapi
would generate from the
Kubernetes OpenAPI specification.
Once you update the dhall-openapi
dependency you can regenerate the
Kubernetes bindings by running:
$ ./scripts/generate kubernetes
The dhall-openapi
dependency is a subproject of the dhall-haskell
repository, so in order to upgrade dhall-openapi
you need to update the
reference to the dhall-haskell
repository.
If you're not prepared to make a pull request to change the dhall-haskell
project then you can generate code for this project using a local checkout of
the dhall-haskell
repository by editing the Nix code like this:
--- a/nix/nixpkgs.nix
+++ b/nix/nixpkgs.nix
json =
builtins.fromJSON (builtins.readFile ./dhall-haskell.json);
- dhall-haskell = pkgsNew.fetchFromGitHub {
- owner = "dhall-lang";
- repo = "dhall-haskell";
- inherit (json) rev sha256 fetchSubmodules;
- };
+ dhall-haskell = ~/path/to/dhall-haskell;
in
(import "${dhall-haskell}/default.nix").dhall-openapi;
Once you do change the upstream dhall-openapi
project, then you can pick up
the change here by runing:
$ nix-prefetch-git --fetch-submodules https://github.com/dhall-lang/dhall-haskell.git > ./nix/dhall-haskell.json
If you're not sure what files you need to regenerate then you can generate
everything by running the generate
script with no arguments:
$ ./scripts/generate
If you want to upgrade to a newer revision of Nixpkgs, then run:
$ nix-prefetch-git https://github.com/NixOS/nixpkgs.git "${REVISION}" > ./nix/nixpkgs.json
All tests are defined in release.nix
. We run these tests in CI in a Hydra
project.
You can run the tests locally with the following command:
nix build --file ./release.nix