Writing in Terraform configuration syntax often violates the DRY principle due to lack of named types and other features. Let's fix it!
This repo contains a Nix
eDSL which can be translated into HCL, a language used by Terraform. In other words, we can use the power of Nix to generate understandable HCL expressions.
- The main drawback of the current approach is that it doesn't use the NixOS module system.
- There are Unsupported constructs
- Pulumi - infrastructure as code
- Terraform CDK - provision infrastructure using familiar programming languages
- nickel - a new configuration language
- terranix - a popular tool that generates
terraform.json
- nixform - another tool that generates
terraform.json
from a Nix eDSL - xinomorf - a pretty outdated tool that generates terraform configurations
- language definition
- eDSL implementation
- expressions for Terraform's built-in functions
- anti-boilerplate functions
- expressions used in tests
- scripts
- to generate Terraform files from the given
Nix
expressions - to naively convert the existing Terraform files into
.nix
files (see Tests)
- to generate Terraform files from the given
-
Knowledge of Nix syntax
- Nix Language
- more specifically, Data Types
- Learn Nix in Y minutes
- Nix Language
-
(Optionally) Understanding of Flakes
- Flakes info
This is one of the test expressions. It's taken from here. The below Nix snippets are in let in
blocks to have a better syntax highlighting.
There are 2 apps: app_purescript
and app_python
. Each of them has a server written in a corresponding language, and these servers show the current time in a browser. Their Docker containers were pushed to Docker Hub. On a host, these apps are under the directories ./app_purescript
and ./app_python
. In Docker containers, each app's code is placed under the /app
directory.
There is some data that is needed to run the apps. This data is described in dockerVariables
. Here, notable fields are: DOCKER_PORT
- internal port inside a Docker container, HOST
- the address of a host on which to run the container, NAME
- a new name for a container, PORT
- the external port. It is worth to mention that all fields of the object in type
are the same for both apps, except for the HOST_PORT
. Also, the names of their variables differ. We can use Nix
functions to use the same template inside dockerVariables
to declare the variables for both apps:
let
appPurescript = "app_purescript";
appPython = "app_python";
apps = [ appPurescript appPython ];
# x may have a custom __toString
_mod = x: { _ = "${toString x}"; try = "try_${x}"; path = "path_${x}"; renamed = "renamed_${x}"; };
# we can apply a modifier _mod to consistently use the forms of `app`
dockerVariables = mkVariables (modifyMapMerge apps _mod (app:
{
"${app._}" = {
type = object {
DIR = optional string "/app";
DOCKER_PORT = optional number 80;
HOST = optional string "0.0.0.0";
NAME = optional string "${app.renamed}";
HOST_PORT = number;
};
};
}));
in
When producing HCL, we'll get such code in variables.tf
:
variable "app_purescript" {
type = object({
DIR = optional(string, "/app")
DOCKER_PORT = optional(number, 80)
HOST = optional(string, "0.0.0.0")
HOST_PORT = number
NAME = optional(string, "renamed_app_purescript")
})
}
variable "app_python" {
type = object({
DIR = optional(string, "/app")
DOCKER_PORT = optional(number, 80)
HOST = optional(string, "0.0.0.0")
HOST_PORT = number
NAME = optional(string, "renamed_app_python")
})
}
Next, we need the values. We can supply just the necessary data, namely HOST_PORT
-s, and a set of variables. The mapping between the set of names in dockerTfvars
and the set of names in dockerVariables
should be injective. The missing values will be generated automatically according to their variables' types. Thus, if necessary, we can supply the empty set to mkVariableValues
. In this case, the Nix expression will be:
let
dockerTfvars = mkVariableValues dockerVariables {
"${appPython}" = {
HOST_PORT = 8002;
};
"${appPurescript}" = {
HOST_PORT = 8003;
};
};
in
These tfvars
will be mapped over the supplied set of variables
to produce a set of values in terraform.tfvars
:
app_purescript = {
DIR = "/app"
DOCKER_PORT = 80
HOST = "0.0.0.0"
HOST_PORT = 8003
NAME = "renamed_app_purescript"
}
app_python = {
DIR = "/app"
DOCKER_PORT = 80
HOST = "0.0.0.0"
HOST_PORT = 8002
NAME = "renamed_app_python"
}
Furthermore, we will be able to use the accessors like var.app_python.HOST_PORT
to get the same expression (var.app_python.HOST_PORT
) in Terraform code.
Finally, we compose the main Terraform file.
There is a couple of rules. We should place a block A
before the block B
if:
-
B
depends onA
, e.g., uses its accessor- Here,
resource.docker_container.${app.try}
accessesvar.${app._}.DIR;
. Hence, thisvar
should be supplied by previos expressions.
- Here,
-
A
should be rendered beforeB
- Notice that in the corresponding Terraform code, the
terraform
block is placed before locals andresource.docker_image
. - On the other hand, as
resource.docker_image
andlocals
are given in the same set,locals
precedes theresource
blocks. This happens becauselocals
is lexicographically smaller thanresource
and becauseNix
orders the elements of a set lexicographically.
- Notice that in the corresponding Terraform code, the
Again, we use the same template to declare the docker_image
and both locals
. These locals
assume that main.tf
is at ./terraform/docker/main.tf
. So, the whole expression is:
let
dockerMain = with _lib;
mkBlocks_ dockerTfvars.__
{
terraform = b {
required_providers = b {
docker = a {
source = "kreuzwerker/docker";
version = "~> 2.22.0";
};
};
};
}
(__: with __; modifyMapMerge apps _mod (app: {
resource.docker_image = {
"${app.try}" = b {
name = "dademd/${app._}:latest";
keep_locally = false;
};
};
locals = b {
# bb means x: "${x}"
"${app.path}" = abspath [ "${bb path.root}/../../${app._}" ];
};
}))
# this __ is to pass the variables created in previous expressions into this one
# alternatively, here, we can write __.var."${app}".DIR;
(__: with __; modifyMapMerge apps _mod (app: {
resource.docker_container.${app.try} = b {
image = docker_image.${app.try} "image_id";
name = app.try;
restart = "always";
volumes = b {
container_path = var.${app._}.DIR;
host_path = local."${app.path}";
read_only = false;
};
ports = b {
internal = var.${app._}.DOCKER_PORT;
external = var.${app._}.HOST_PORT;
};
env = [ "HOST=${bb var.${app._}.HOST}" "PORT=${bb var.${app._}.DOCKER_PORT}" ];
host = b {
host = "localhost";
ip = var.${app._}.HOST;
};
};
}));
in
In fact, we can supply the missing accessors via a sequence of strings, as in image = docker_image.${app.try} "image_id";
. This is useful when a block was declared by a provider, and we don't want to rewrite it and supply as a Nix expression.
The corresponding main.tf
:
terraform {
required_providers = {
docker = {
source = "kreuzwerker/docker"
version = "~> 2.22.0"
}
}
}
locals {
path_app_purescript = abspath("${path.root}/../../app_purescript")
path_app_python = abspath("${path.root}/../../app_python")
}
resource "docker_image" "try_app_purescript" {
keep_locally = false
name = "dademd/app_purescript:latest"
}
resource "docker_image" "try_app_python" {
keep_locally = false
name = "dademd/app_python:latest"
}
resource "docker_container" "try_app_purescript" {
env = [
"HOST=${var.app_purescript.HOST}",
"PORT=${var.app_purescript.DOCKER_PORT}"
]
host {
host = "localhost"
ip = var.app_purescript.HOST
}
image = docker_image.try_app_purescript.image_id
name = "try_app_purescript"
ports = {
external = var.app_purescript.HOST_PORT
internal = var.app_purescript.DOCKER_PORT
}
restart = "always"
volumes = {
container_path = var.app_purescript.DIR
host_path = local.path_app_purescript
read_only = false
}
}
resource "docker_container" "try_app_python" {
env = [
"HOST=${var.app_python.HOST}",
"PORT=${var.app_python.DOCKER_PORT}"
]
host {
host = "localhost"
ip = var.app_python.HOST
}
image = docker_image.try_app_python.image_id
name = "try_app_python"
ports = {
external = var.app_python.HOST_PORT
internal = var.app_python.DOCKER_PORT
}
restart = "always"
volumes = {
container_path = var.app_python.DIR
host_path = local.path_app_python
read_only = false
}
}
Overall, we can notice that definitely a lot of expressions have almost complete duplicates. We believe that whenever this is the case, such expressions should be generated from DRY Nix code.
- Enter the repo
git clone https://github.com/deemp/terrafix
- Run tests. This will also write into Terraform files expressions generated from test-data. Also, the tests will quite naively translate these expressions back into Nix under './tf2nix'
nix run .#testCases
- Convert a
.tf
file into a.nix
one and get the output under./converted
:
nix run .#convertTf2Nix docker/main.tf
To run the individual tests, see the outputs of
nix flake show
And then run one of them:
nix run .#testDocs
The author is not an experienced Terraform user. That's why, he may have missed some Terraform's features that can make one's code DRY.
The author likes Nix and likes generating configs using this language.
There are some HCL constructs that aren't yet supported. To name a few:
- modules and output variables
- Modules should probably be implemented similarly to
mkBlocks
- output variables - as
resources
andvariables
- Conditional expressions
- They require making conditional accessors or merging accessors for objects in both options
- Possibly, some built-in functions
- For now, they're all constructed using simple language constructs. It's highly likely that some of them use more advanced constructs (to be checked)
- Output variables
- Need a function that will extract them from blocks
- Indices and splat expressions
- can pass them as special objects inside lists to accessors, like
some_list [_ast_]
to meansome_list[*]
-
__functor
like in accessors to allow continuing the expressions after functions likevalues(aws_instance.example)[*].id
-
for
expressions and conditionals
-
terrafix
is an eDSL and it doesn't suggest any new infrastructure or mechanisms. It is a means to produceTerraform
code (.tf
,.tfvars
) fromNix
code. On the other hand,terranix
suggests a module system, which is different from the Terraform's one, documentation generation, and it compiles to.json
. -
In the author's opinion, for debugging, finding the mapping between
.tf
files and.nix
files is easier than between.json
and.nix
. Also, one may utilize Terraform's Language Server to find the errors in the generated code. To add,terrafix
looks pretty similar to HCL, so there is a highly error-prone script to convert the existing Terraform files into.nix
. It worked for simple examples though. -
Currently,
terrafix
has similar syntactic sugar, and, hopefully, the same compile-time safety due toaccessors
. Interrafix
, it's possible to use an accessor generated from previous blocks + add the missing fields:image = config.resource.hcloud_server.myserver.image "id";
This will look like
image = config.resource.hcloud_server.myserver.image.id
in the Terraform code. Additionally, there will be a Nix compile time error if such an accessor is missing. Please, search the wordaccessor
on the README page ofterrafix
to see more examples.In
terranix
, one may writeimage = config.resource.hcloud_server.myserver.image;
However, the author is not sure if in
.json
it won't become a string stored inconfig.resource.hcloud_server.myserver.image
. -
terrafix
can (naively) render all Terraform's standard functions. There seems to be no such functionality interranix
-
terrafix
is an experimental language, so it has a lot of limitations. E.g. modules likedockerMain
can only be mixed into an argument ofmkBlocks_
of another module to make their variables available inside. See more limitations.
issues and Pull requests are welcome! E.g. you may submit a missing language feature request or implementation.
See these for additional info:
- codium-generic - info just about
VSCodium
and extensions. - nix-vscode-extensions (pinned here).
- Prerequisites.
- Troubleshooting
-
Run
devShells
in this repo directory.-
Option 1: run
direnv allow
to usedirenv
. -
Option 2: run
devShells
manually.nix develop nix-dev/ nix develop
-
-
(Optionally) Start
VSCodium
.nix run nix-dev/#codium .
- .markdownlint.jsonc - for
markdownlint
from the extensiondavidanson.vscode-markdownlint
- .envrc - for direnv.
- ci.yaml - a generated
GitHub Actions
workflow. See workflows.