Using Terraform’s try() ,can(), and input validation

HahsiCorp has added two new tools in Terraform. As of Terraform v.12.20 there are two new functions available for consumers try() and can() . Along with these two functions there is an experimental feature available, variable_validation . In this article we’re going to look into how these new functions are used and how they works.

This article was originally published on Medium. Link to the Medium article can be found here.

All code snippets can be found at https://github.com/karl-cardenas-coding/terraform-functions

Note: Variable validation is an experimental feature as of v12.20 use with caution as it is not recommended for production usage at this time.

The can and try()function can only catch and handle dynamic errors resulting from access to data that isn’t known until runtime. It will not catch >errors relating to expressions that can be proven to be invalid for any input, such as a malformed resource reference.

Can()

The can() function attempts to execute the following code provided inside of it and returns a boolean value. The main purpose behind can() function is input validation according to the official documentation. Let’s put it to test.

To enable input_validation add the following code block to your Terraform configuration.

1
2
3
terraform {
  experiments = [variable_validation]
}
1
2
3
4
5
6
7
8
9
variable "os" {
  default = "linux"

  validation {
    # The condition here identifies if the variable contains the string "linxu" OR "windows".
    condition = can(regex("linux|windows", var.os))
    error_message = "ERROR: Operating System must be Windows OR Linux."
  }
}

Shout out to @d-henn for the regex example

In the example above we have a variable named “os”, short for “operating system”. This variable is also leveraging the new validation functionality. So let’s break things down here. The validation block has two components:

  • condition (required)
  • error_message (required) (does NOT support interpolation)

The syntax for the can() is can(logic for test, value or variable to test) . In the example above, the variable had the value “linux” hard-coded as a default value. Let’s change that to another value, say “z/OS” and see how it behaves on a terraform plan or a terraform apply

1
2
3
4
5
6
7
8
Error: Invalid value for variable

on can.tf line 12:
12: variable "os" {

ERROR: Operating System must be Windows OR Linux.

This was checked by the validation rule at can.tf: 15, 3-13.

Pretty neat! The error message is pretty descriptive due to our ability to author it. Terraform also returns the file name and location in the file for where the incorrect variable value is can.tf:15,3–13.

Fun fact, variable validation is opinionated as it expects proper English grammar 👵🏻.

1
2
3
4
5
6
7
Error: Invalid validation error message

on can.tf line 18, in variable "os":
18: error_message = "ERROR: Operating System must be Windows OR Linux"

Validation error message must be at least one full English sentence starting
with an uppercase letter and ending with a period or question mark.

Input validation can also be used without the can() . In the code snippet below you can see that the length of the variable “word” is evaluated to see if it is greater than 1. The variable is tied to the random pet provider and will dictate how many pets are in the word string generated.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
### Test scenario for "can"
variable "word-length" {

  validation {
    # The condition here identifies if the integer if greater than 1
    condition = var.word-length > 1
    error_message = "The variable is not greater than 5. Word length has to be at a minimum > 1."
  }
}

variable "os" {

  validation {
    # The condition here identifies if the variable contains the string "linxu" OR "windows".
    condition = can(regex("linux|windows", var.os))
    error_message = "ERROR: Operating System must be Windows OR Linux."
  }
}

resource "random_pet" "pet" {
  length = var.word-length
  keepers = {
    pet-name = timestamp()
  }
}


output "pet" {
  value = "${random_pet.pet.id}"
}

HashiCorp does call out in their documentation that can() should not be used for error handling, or any context outside of input validation (though technically possible). For those other scenarios try() is recommended. So on that note, let’s move on to the try()

Try()

The try() evaluates all [arguments …] expression passed into it, and it will return the value of the 1st one that does not return an error. At this time, try() is recommended for use in local values variables. The main reason behind this recommendation is for reduced code complexity and to only use it for normalization. Perhaps future use will allow consumers to wrap resources blocks inside a try() ?

Before we dive the into example code below, let’s talk through it at a high level.

Purpose: To query an endpoint. This query retrieves all public IPs of the AWS services. https://ip-ranges.amazonaws.com/ip-ranges.json

How: The Terraform data resource http will query Amazon’s endpoint that return a JSON response.

Locals: This is where our try() logic will come into action, for our three variables, syncToken, services, and regions .

Reason: The reasoning behind the try() blocks is in case Amazon changes the JSON schema or perhaps the service(s) are no longer exposing public IPs. If a breaking change were to occur then we can take that into account.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
# Try example
data "http" "primary-server" {
  url = "https://ip-ranges.amazonaws.com/ip-ranges.json"

  # Optional request headers
  request_headers = {
    Accept = "application/json"
  }
}


locals {
# This returns the sync token from the endpoint, the return value is of the type string.
  syncToken = try(jsondecode(data.http.primary-server.body).syncToken,
              "NO TOKEN AVAILABLE"
              )

# This variable holds the all the unique regions returned by the endpoint. The return value is of the type list OR a string error value.
  regions = try(distinct([
    for items in jsondecode(data.http.primary-server.body).prefixes:
    items.region
  ]), "NO LIST PROVIDED IN LOCALS REGION VARIABLE")

# This variable holds the all the unique services returned by the endpoint. The return value is of the type list OR a string error value.
  services = try(distinct([
    for items in jsondecode(data.http.primary-server.body).prefixes:
    items.service
  ]), "NO LIST PROVIDED IN LOCALS SERVICES VARIABLE")

# This variable holds the all the IPs addresses for the S3 service returned by the endpoint. The return value is of the type list OR a string error value.
  s3_ips = try(distinct([
      for items in jsondecode(data.http.primary-server.body).prefixes:
      items.ip_prefix if items.service == "S3"
    ]), "NO LIST PROVIDED IN LOCALS SERVICES VARIABLE")

}

output "response-json-syncToken" {
  value = local.syncToken
}

output "response-json-s3-ips" {
  value = local.s3_ips
}

output "response-json-regions" {
  value = local.regions
}

output "response-json-services" {
  value = local.services
}

So with that explanation out of the way, let’s dive into the local block. The first variable syncToken , attempts to use the jsondecode on the response from data.http.primary-server.body .

1
2
3
syncToken = try(jsondecode(data.http.primary-server.body).syncToken,
              "NO TOKEN AVAILABLE"
              )

The jsondecode functions converts a JSON object into HCL, which then allows us to access the the syncToken value by using dot notation at the end. If the server were to not return a syncToken then we have a fallback value of “NO TOKEN AVAILABLE”. The fallback value can be set to anything the consumer desires. Without the try() function, this would had resulted in an ugly error.

Without the try() block this stopped our Terraform run. Observe the results below with the try() block in our code.

1
2
3
4
5
Apply complete! Resources: 0 added, 0 changed, 0 destroyed.

Outputs:

response-json-syncToken = NO TOKEN AVAILABLE

Although we didn’t receive the syncToken from the endpoint, our fallback value kicked in and our Terraform run was able to complete. Neat!

For()

*Skip to conclusion if you are familiar with the for loop

To retrieve the filtered list of all AWS S3 IP addresses, we can simply leverage the Terraform for loop expression. If you haven’t played with the for loop don’t let the code below intimidate you. Allow me to break it down line by line:

  1. Wrap it around a try() #line 3

  2. Retrieve all unique values through the distinct() #line 3

  3. We want the results to be of the type list, so let’s use [] and wrap our for loop inside of it #line 3

  4. Declare the loop using the keyword for . Let’s use a temporary variable named items to hold each unique value as we iterate through the provided list. The provided list in this loop will be the resource attribute reference from data.http.primary-server.body . However, because we don’t want to work with a raw JSON object, let’s convert it to HCL type so we can use dot notation. Therefore we wrap the list inside jsondecode() . Lastly, because we can use expect dot notation, let’s go after the prefixes attribute in our object. #line 4

  5. As we iterate trough our loop, let’s add each IP address, but only if its service is S3 #line 5

  6. Let’s close out our loop, and pass the fallback value should the list we passed to the loop not exist. In this case, we return a string explaining the issue.

It helps to look at the JSON object from https://ip-ranges.amazonaws.com/ip-ranges.json to better understand the dot notation

1
2
3
4
5
6
7
8
locals {  

s3_ips = try(distinct([  #distinct() is not needed but added to showcase the wrap of functions before the loop
      for items in jsondecode(data.http.primary-server.body).prefixes:
      items.ip_prefix if items.service == "S3"
    ]), "NO LIST PROVIDED IN LOCALS S3 VARIABLE")

 }

Conclusion

The can() and try() functions are pretty neat and will certainly add a lot of value to consumers’ configuration templates. Just remember the following rules of thumb before starting to use these new functions.

  • can() is intended for the experimental feature input_validation and the expression must return a boolean.
  • try() should be used for operations related to normalization, preferably in the locals{} code block.
  • try() will return the first non-error resulting expression if multiple arguments are provided [arguments…].
  • Neither function can handle dynamic errors resulting from access to data that isn’t known until runtime.

Hopefully you learned something new! Get out there and automate your life away from ClickOps!

0%