terraform
Terraform learned to say what it assumed

Terraform 1.2 came out in the middle of May, and the feature I have used every
week since is the least exciting one in the announcement: precondition and
postcondition blocks.
They are the kind of addition that does not enable anything new. What they change is where you are allowed to write something down, and that turns out to matter more than the capability itself.
The failure they are for
Every module I have written has assumptions in it that are not expressed anywhere. The AMI passed in is expected to be x86. The subnet is expected to be private. The instance type is expected to support the number of network interfaces the rest of the configuration attaches to it.
When one of those is wrong, Terraform does not tell you it is wrong. It tells you something else is wrong, forty lines into an apply, in the words of the provider rather than the words of the assumption. The AWS API refuses a call, you read an error about an unsupported parameter combination, and you spend twenty minutes working backwards to the input that caused it.
Variable validation, added back in 0.13, covers part of this. But it only sees the variable, so it can check a string against a regex and nothing else. The assumptions that actually break are about relationships between values, and half of them involve data that Terraform only learns by looking something up.
What they look like
A precondition sits on the thing that holds the assumption:
data "aws_ami" "app" {
most_recent = true
owners = ["self"]
filter {
name = "name"
values = [var.ami_name]
}
}
resource "aws_instance" "app" {
ami = data.aws_ami.app.id
instance_type = var.instance_type
subnet_id = var.subnet_id
lifecycle {
precondition {
condition = data.aws_ami.app.architecture == "x86_64"
error_message = "The selected AMI is ${data.aws_ami.app.architecture}; this instance type needs x86_64."
}
}
}
The error arrives during plan, it names the assumption, and it interpolates the value that violated it — error messages became full expressions in this release, which is what makes them worth reading.
A postcondition checks the result rather than the input, which is the one I reach for with data sources:
data "aws_subnet" "chosen" {
id = var.subnet_id
lifecycle {
postcondition {
condition = self.map_public_ip_on_launch == false
error_message = "Subnet ${var.subnet_id} assigns public IPs; this module expects a private subnet."
}
}
}
self inside a postcondition refers to the object being checked. The pattern I
have settled on is simple: preconditions guard what a resource is about to be
given, postconditions assert what a lookup came back with.
Why the location matters
All of this was possible before. You could contort a validation block, or add a
null_resource with a count that fails to evaluate, or write the check in the
pipeline. People did all three, and I have written two of them.
What was not possible was putting the assumption next to the resource that holds
it. An assumption written in a pipeline step is documentation that drifts. An
assumption written in the lifecycle block of the resource it constrains is read
by the next person at exactly the moment they need it, because they are already
looking at that resource — they are editing it.
That is the whole value, and it is the same argument as a type annotation or a test name. The check is worth something. Being unable to put it in the wrong place is worth more.
What I would not use it for
Not for policy. "No public S3 buckets in this account" is not a module assumption, it is an organisational rule, and it belongs somewhere that applies whether or not a particular module was used. Preconditions live inside the code they guard, which is exactly the wrong place for a rule meant to constrain code nobody has written yet.
And not as a substitute for the provider's own validation. If the API will reject it clearly, let it. The checks worth writing are the ones where the failure would otherwise surface somewhere that does not mention the cause.
The other addition in 1.2 I have found a use for is replace_triggered_by,
which replaces a resource when an upstream dependency changes — a tidier answer
than the hash-in-a-tag trick I had been using to force replacements. Same
character as the conditions: not new power, just a supported way to say a thing
people were already saying badly.
Written by
Deyan Peev
Founding Engineer · Sofia, Bulgaria


