TT Lab
Get started
Learn Learning paths Courses

Terraform/OpenTofu Fundamentals

The Grammar of Making Values

Continue in TT Lab

In one line

Built-in functions let you make values in a configuration file by computing them, and you can check the result on one line in the console before applying.

Why functions were needed

The first impression of a declarative configuration is "you write values as they are." But once it grows a little, things come up that cannot be written as they are. Names are made by combining environment, service, and number; tags are made by merging a common share and an individual share; and ports differ by environment. If you write these as values, every time an environment is added the places to fix are scattered across several spots.

Worse is serialization joined by hand. If you build a JSON configuration by adding strings, a person becomes responsible for quotes and escapes, and it breaks the moment a double quote comes into a value. So functions came into the language.

How it works

Functions divide into a few broad branches. Those that handle strings, those that handle collections, those that convert types, those that handle failure, and templates and serialization.

join("-", ["web", "prod", "01"])        -> "web-prod-01"
format("%s-%03d", "node", 7)            -> "node-007"
merge({ a = 1 }, { a = 9, b = 2 })      -> { a = 9, b = 2 }   뒤가 이긴다
lookup({ web = 80 }, "api", 0)          -> 0                  없으면 기본값
distinct(["a", "b", "a"])               -> ["a", "b"]
formatlist("%s=%d", names, ports)       -> 두 목록을 나란히 돌며 서식 적용

A conditional expression has the form 조건 ? 참일때 : 거짓일때 (condition ? if true : if false). The types of the two branches must be the same, and if they differ, the tool tries to unify them into one and an unexpected conversion arises.

The two functions that handle failure differ in nature. try gives the next argument if evaluating the earlier argument fails, and can turns success or failure into true or false. There is an important limit — both catch only errors that arise at run time. Referencing a variable or locals that is not even declared is a static error, so it cannot be wrapped even by try.

locals {
  cfg  = try(jsondecode(file("${path.module}/in.json")), {})  # 깨진 JSON 이면 빈 맵
  name = try(local.cfg.name, "unknown")                        # 키가 없으면 기본값
}

Templates are used when there are several values. Inside a template file you can use repetition and conditional directives, and you attach a whitespace-strip marker so that the directive lines do not remain as blank lines in the result.

%{ for p in ports ~}
  listen ${p};
%{ endfor ~}

Serialization functions convert a value into JSON or YAML notation. You do not have to attach quotes by hand, and above all when you read it back, the same data comes out. If you export the same map with the two functions and then read each back, you can confirm that they are exactly the same.

Type conversion functions are nice because they have no silent failure. When they meet a value that cannot be converted, they raise an error right there.

tonumber("007")        -> 7
parseint("ff", 16)     -> 255
tostring(true)         -> "true"
tonumber("abc")        -> 오류
try(tonumber("abc"), -1) -> -1

And the place to check all of this without applying is the console. The console reads the variables and locals of the current directory, so you can evaluate different branches with different variable values without fixing the configuration.

What you see in the field

What you see most often is code that left out the default of lookup. If you write it believing the key will always be in the map, then on the day you add a new environment, the apply stops only in that environment. The single habit of giving a default makes this whole class of accidents vanish.

The second is misunderstanding try as a shield. Quietly falling back to a default when a value is odd is not always right. If the configuration is wrong but the apply succeeds with the default, the problem shows up in production weeks later. It is better to use it for data coming in from outside and not for values inside our own repository.

The third is a configuration that strung strings together without using a template. At first it is short and looks fine, but with just two conditions it becomes unreadable. If you pull a multi-line result out into a template file, you can guess the result just by looking at that file.

What you will do in the next lab

You go through eight steps. You check six string functions in the console, export functions that merge and pick from maps as real outputs, and choose per-environment values with conditional expressions. Then you build a configuration that absorbs broken JSON, stamp out a six-line configuration file with a template, and export the same data in two formats to confirm that they are the same when read back. In the last two steps, you check the conversion functions and their failures, and build a configuration with not a single value written directly, to confirm that the result follows even when the grader applies it with different inputs.