Description
So far in my use of Terraform in my workplace I've uncovered a number of use-cases where it's useful to generate Terraform configurations based on outside data.
So far I've been handling that by having users run extra preparation steps before they run terraform plan
, which write JSON-style Terraform configs into a directory. This has worked okay, but the need for extra steps means that the standard usage pattern of Terraform does not apply.
Based on what I've learned from generating Terraform configs, I'd like to offer a proposal for integrating the concept of configuration generation directly into Terraform. This is an attempt to "pave the cowpath" by taking a pattern that I've already successfully applied and building Terraform syntax around it.
Consider the following configuration as a motivating example:
// Load a directory full of files into an S3 bucket
resource "aws_s3_bucket_object" "file" {
foreach "dir_entry" {
path = "${path.module}/htdocs"
recursive = true
include_dirs = false
}
bucket = "${var.s3_bucket_name}"
key = "${foreach.relative_path}"
source = "${foreach.absolute_path}"
}
(This example is inspired by my terraform-s3-dir utility.)
The resource construct is extended with a new child block foreach
, which is conceptually similar to count
but rather than producing resources based on a sequence of numbers it produces resources based on executing a generator plugin, which in this example is dir_entry
.
The contract for a generator plugin is to take the given input arguments and produce (essentially) a map[string]interface{}
, where each map key is a hashable unique identifier for an object and the value is a structure that can be used from the ${foreach...}
interpolation syntax.
Some details in the sections that follow...
Effect on terraform plan
As noted earlier, foreach
is conceptually similar to count
in that it causes one resource configuration block to produce multiple resource instances. In the case of count
these are named like aws_s3_bucket_object.file.0
. In the case offoreach
a similar convention applies except that the index is replaced by the result of applying`hashcode.String`` to each item's key.
Since the resources are identified by a hash of their key, diffing can produce a sensible result as long as the keys remain consistent between runs. In the dir_entry
example above the object key could be the same as the relative_path
attribute value, so adding and removing files would cause the corresponding resource instances to be added and removed in the diff.
foreach
configuration block
Since generation is a plan-time concept, the foreach
block may only contain interpolations that are known statically, such as var
, path
, interpolation functions. It specifically may not reference resource or module attributes.
Otherwise the structure of the configuration block is under the control of the generator plugin, much as with provisioners.
${foreach...}
interpolation syntax
The ${foreach...}
interpolation syntax is again comparable to the ${count...}
syntax, but the attributes within it depend on the structure returned by the generator function. Each generator is free to define its own set of attributes in an arbitrary hierarchical structure, just like resources can.
Generator plugin interface
Generator plugins have a similar interface to provisioner plugins, including the same configuration validation methods but with the Apply
method replaced with Generate(*ResourceConfig) map[string]interface{}
.
It is expected that a generator will produce tens of items at most, so returning the whole structure in-memory (rather than streaming it e.g. using a goroutine) should be sufficient. A generator producing hundreds or thousands of items would result in hundreds or thousands of Terraform resources, which I believe is already beyond Terraform's design assumptions.
Whereas provisioners are verbs, generators should be named as nouns describing what kind of items the generator produces, so that the declaration reads as (for example) "For each directory entry...".
Interaction with count
To avoid the combinatoric complexity that would result, using count
and foreach
together in the same resource block is not permitted.
Additional Example Use-cases
Some further examples of generators plugins that might be implemented, and what they could be used for...
DNS records from a standard zone file
resource "aws_route53_record" "foo" {
foreach "dns_zone_record" {
// A "zone file" per RFC1035
zone = "${file('example.com.zone')}"
// Disregard SOA records
ignore_types = ["SOA"]
}
zone_id = "${var.route53_zone_id}"
name = "${foreach.name}"
type = "${foreach.type}"
records = ["${foreach.records}"]
ttl = "${foreach.ttl}"
}
Users (or indeed anything else) from a YAML file
resource "aws_iam_user" "user" {
foreach "yaml_entry" {
// Give a YAML document that either has a mapping or a list at its root,
// to produce one resource instance per entry in that structure.
source = "${file('users.yaml')}"
}
name = "${foreach.username}"
path = "/staff/"
}
A custom external program for generating VPN routes
resource "aws_vpn_connection_route" "route" {
foreach "local_exec_result" {
// Any executable that produces a JSON object as output and
// exits with a successful status.
command = "${path.module}/generate-route-map"
}
destination_cidr_block = "${foreach.cidr_block}"
vpn_connection_id = "${aws_vpn_connection.main.id}"
}