Using Dhall to manage my docker-compose.yml
Posted on
I saw Dhall mentioned on a job posting for NoRedInk. I had never heard of it before but NoRedInk did create Elm and are fond of functional tooling so it piqued my interest.
For reference, Dhall is a simple language. It's not Turing complete, but it's strongly typed. It's primarily used for managing configuration files. It has some helpers to output JSON/YML/bash, but you can also output to plain text.
The website does a good job of showing what it can do and its use cases so I wont dive into those. Converting my docker-compose.yaml that I use to manage my media server was the first usecase I could think of.
I wont be posting the full source but I hope I convey how much fun I had working with Dhall.
# Experiment
I'll just be focusing on a small part of the docker-compose file, namely the ports.
Let's say this is my compose file
services:
plex:
image: linuxserver/plex:latest
ports:
- 32400:32400
- 32400:32400/udp
environment:
VERSION: "latest"
A quick translate over would get us
let plex =
{ image = "linuxserver/plex:latest"
, ports = [ "32400:32400", "32400:32400/udp" ]
, environment.VERSION = "latest"
}
in { services.plex = plex }
When passed into dhall
gets us this:
>>> dhall --file test.dhall
{ services.plex
=
{ environment.VERSION = "latest"
, image = "linuxserver/plex:latest"
, ports = [ "32400:32400", "32400:32400/udp" ]
}
}
dhall-to-yaml
produces the following:
>>> dhall-to-yaml --file test.dhall
services:
plex:
environment:
VERSION: latest
image: linuxserver/plex:latest
ports:
- "32400:32400"
- "32400:32400/udp"
Which is the exact same file (keys are sorted so the output is deterministic).
I always forget what order the ports should go (host:container) so let's take advantage of Dhall to make it easier on me. Before I make any changes to the file I'm going to be grabbing the Dhall SHA so I can compare it to my refactored code's SHA, if they are equal then the output has not changed.
>>> dhall hash --file test.dhall
sha256:db23b00a8e79e084306b72ecc788f0b95579080c883cb72045a3ae6ea43999fd
First lets move ports
into it's own variable and verify the hash
let ports
: List Text
= [ "32400:32400", "32400:32400/udp" ]
let plex =
{ image = "linuxserver/plex:latest"
, ports
, environment.VERSION = "latest"
}
in { services.plex = plex }
>>> dhall hash --file test.dhall
sha256:db23b00a8e79e084306b72ecc788f0b95579080c883cb72045a3ae6ea43999fd
OK we can be sure we didn't change our output at all. Don't worry about : List Text
that is just a type annotation.
Lets start by making a type that we can store the port mapping in and a function to convert it to text.
let PortMapping
: Type
= { host : Natural, container : Natural }
let PortMapping/show
: PortMapping -> Text
= \(pm : PortMapping) ->
"${Natural/show pm.host}:${Natural/show pm.container}"
Now we can supply the ports we want in any order and be confident the output order is correct.
Let's update ports
to use this new functionality.
let ports
: List Text
= [ PortMapping/show { host = 32400, container = 32400 }
, "${PortMapping/show { host = 32400, container = 32400 }}/udp"
]
Our hash is still sha256:db23b00a8e79e084306b72ecc788f0b95579080c883cb72045a3ae6ea43999fd
so we haven't broken anything yet.
This is better but we still need to do some string interpolation if we want to create a UDP port mapping.
The docker documentation says the notation without /udp
or /tcp
will default to tcp.
Let's create an enum that will represent these two and update our code to use it.
let Port
: Type
= < tcp : PortMapping | udp : PortMapping >
let Port/show
: Port -> Text
= \(p : Port) ->
merge
{ tcp = PortMapping/show
, udp = \(pm : PortMapping) -> "${PortMapping/show pm}/udp"
}
p
let ports
: List Text
= [ Port/show (Port.tcp { host = 32400, container = 32400 })
, Port/show (Port.udp { container = 32400, host = 32400 })
]
Much nicer! And our hash is still producing sha256:db23b00a8e79e084306b72ecc788f0b95579080c883cb72045a3ae6ea43999fd
.
Now what would be even nicer is we did not have to specify the port twice if they are the same value.
Let's do that by expanding our Port
enum to supply a tcp_mirror
and a udp_mirror
.
let Port
: Type
= < tcp : PortMapping
| udp : PortMapping
| tcp_mirror : Natural
| udp_mirror : Natural
>
let Port/show
: Port -> Text
= \(p : Port) ->
let udpPortMapping = \(pm : PortMapping) -> "${PortMapping/show pm}/udp"
in merge
{ tcp = PortMapping/show
, udp = udpPortMapping
, tcp_mirror =
\(port : Natural) ->
PortMapping/show { host = port, container = port }
, udp_mirror =
\(port : Natural) ->
udpPortMapping { host = port, container = port }
}
p
let ports
: List Text
= [ Port/show (Port.tcp_mirror 32400), Port/show (Port.udp_mirror 32400) ]
-- sha256:db23b00a8e79e084306b72ecc788f0b95579080c883cb72045a3ae6ea43999fd
We can include the List/map
function from Prelude
so we do not have manually transform our port list.
#
Final test.dhall
let List/map = https://prelude.dhall-lang.org/List/map
let PortMapping
: Type
= { host : Natural, container : Natural }
let PortMapping/show
: PortMapping -> Text
= \(pm : PortMapping) ->
"${Natural/show pm.host}:${Natural/show pm.container}"
let Port
: Type
= < tcp : PortMapping
| udp : PortMapping
| tcp_mirror : Natural
| udp_mirror : Natural
>
let Port/show
: Port -> Text
= \(p : Port) ->
let udpPortMapping = \(pm : PortMapping) -> "${PortMapping/show pm}/udp"
in merge
{ tcp = PortMapping/show
, udp = udpPortMapping
, tcp_mirror =
\(port : Natural) ->
PortMapping/show { host = port, container = port }
, udp_mirror =
\(port : Natural) ->
udpPortMapping { host = port, container = port }
}
p
let portsList
: List Port
= [ Port.tcp_mirror 32400, Port.udp_mirror 32400 ]
let plex =
{ image = "linuxserver/plex:latest"
, ports = List/map Port Text Port/show portsList
, environment.VERSION = "latest"
}
in { services.plex = plex }
-- sha256:db23b00a8e79e084306b72ecc788f0b95579080c883cb72045a3ae6ea43999fd
# My Conclusion
Using Dhall is super fun. I can totally see the usefulness in a shared team environment for managing configurations. I think I went a bit overboard as my ~120 line compose file is now ~400 lines of Dhall code 😅. Except now it's statically typed and it will be super easy to add new services to my server (or in context of the experiment, new ports to the Plex service).