§ A10 · Track A · CUE from zero
Generating fields from data
Build fields from a list or a struct with for. Choose keys that stay the same when the data is reordered.
On this page
In HCL, count creates one resource per list entry. The position of the entry becomes the identity of the resource.
resource "aws_subnet" "main" {
count = length(var.subnets)
availability_zone = var.subnets[count.index].zone
cidr_block = var.subnets[count.index].cidr
vpc_id = aws_vpc.main.id
}
Here var.subnets is a list of objects with zone and cidr. The instances are aws_subnet.main[0], [1] and [2]. Swap the first two entries of the list, and instance 0 now describes what instance 1 described. The plan replaces both subnets.
With for_each = { for s in var.subnets : s.zone => s }, the instances are named by zone, and the swap changes nothing.
A comprehension builds fields from other data, and you choose the key of each field. for i, z in zones walks a list and gives each position i and each value z. for k, v in zones walks a struct and gives each key and value. Write _ for a value you do not need.
A key can be computed. Parentheses make the key an expression: (z.name) uses the value of z.name as the key. A string with \(...) builds a key from parts: "subnet\(i)" joins subnet and the value of i.
Warm-up
From earlier lessons
A rule buckets: [Name=string]: #Bucket & {name: Name} holds the entry logs: {name: "other"}. Is it accepted?
No. The rule sets name to the key logs, and "other" disagrees (A9).
A field description?: string has no value. Does pravo eval print it?
No. An optional field without a value is left out. The flag -O prints it (A7).
Generate from a list
Work in an empty directory. The data goes in one file and the comprehensions in another. Write zones.cue.
package estate
zones: [
{name: "eu-west-1a", cidr: "10.0.1.0/24"},
{name: "eu-west-1b", cidr: "10.0.2.0/24"},
{name: "eu-west-1c", cidr: "10.0.3.0/24"},
]
Write estate.cue. Two comprehensions turn the list into two structs.
package estate
byIndex: {
for i, z in zones {
"subnet\(i)": z.cidr
}
}
byName: {
for z in zones {
(z.name): z.cidr
}
}
subnet0: "10.0.1.0/24"
subnet1: "10.0.2.0/24"
subnet2: "10.0.3.0/24"
"eu-west-1a": "10.0.1.0/24"
"eu-west-1b": "10.0.2.0/24"
"eu-west-1c": "10.0.3.0/24"
Both comprehensions read the same list and make three fields. They differ only in the key. byIndex takes its key from a position. byName takes its key from a property of the data, the zone.
Reorder the data
Think about each key. Does it still point to the same data?
Predict Swap the first two zones in the list. Which lines of the two results change?
In byIndex, the lines subnet0 and subnet1 change their values. In byName, no line changes its value. Two lines change place.
Swap the first two entries of zones.cue. Nothing else changes.
package estate
zones: [
{name: "eu-west-1b", cidr: "10.0.2.0/24"},
{name: "eu-west-1a", cidr: "10.0.1.0/24"},
{name: "eu-west-1c", cidr: "10.0.3.0/24"},
]
subnet0: "10.0.2.0/24"
subnet1: "10.0.1.0/24"
subnet2: "10.0.3.0/24"
"eu-west-1b": "10.0.2.0/24"
"eu-west-1a": "10.0.1.0/24"
"eu-west-1c": "10.0.3.0/24"
Read the results by key.
| Field | Before | After |
|---|---|---|
byIndex.subnet0 |
10.0.1.0/24 |
10.0.2.0/24 |
byIndex.subnet1 |
10.0.2.0/24 |
10.0.1.0/24 |
byName."eu-west-1a" |
10.0.1.0/24 |
10.0.1.0/24 |
byName."eu-west-1b" |
10.0.2.0/24 |
10.0.2.0/24 |
The key subnet0 held the first zone before the swap and holds the second one after it. The key "eu-west-1a" holds the same subnet in both cases.
See it in a plan
An address is the path of a field from the top of the specification. byIndex.subnet0 and byName."eu-west-1a" are addresses. pravo files one record per resource under its address. A resource made by a comprehension takes your key as the last part of its address.
Make each field a resource, and the same swap becomes a plan. This section uses the local provider from Start. Each resource is a file under out/ whose content is the cidr. pravo init needs the network once, to fetch the provider. Track B explains every command.
package infra
zones: [
{name: "eu-west-1a", cidr: "10.0.1.0/24"},
{name: "eu-west-1b", cidr: "10.0.2.0/24"},
{name: "eu-west-1c", cidr: "10.0.3.0/24"},
]
package infra
import loc "pravo.dev/providers/hashicorp/local"
$local: loc.#Provider
byIndex: {
for i, z in zones {
"subnet\(i)": $local.#Resource.local_file & {
filename: "out/index-\(i).txt"
content: z.cidr
}
}
}
byName: {
for z in zones {
(z.name): $local.#Resource.local_file & {
filename: "out/\(z.name).txt"
content: z.cidr
}
}
}
Configuration does not load yet; generating schemas for the declared providers first.
...
pravo has been initialised. Run "pravo plan" to see what it would do.
Apply complete! Resources: 6 added, 0 changed, 0 destroyed. [txn <id>]
byIndex.subnet0
byIndex.subnet1
byIndex.subnet2
byName."eu-west-1a"
byName."eu-west-1b"
byName."eu-west-1c"
The state holds one record per address. Now swap the first two zones, as before.
package infra
zones: [
{name: "eu-west-1b", cidr: "10.0.2.0/24"},
{name: "eu-west-1a", cidr: "10.0.1.0/24"},
{name: "eu-west-1c", cidr: "10.0.3.0/24"},
]
Think about each address. Does the data at that address change?
Predict Which of the six resources does the plan replace?
The two at byIndex.subnet0 and byIndex.subnet1. Their data changed. The four others are skipped.
# byIndex.subnet0 (local_file) must be replaced
# byIndex.subnet1 (local_file) must be replaced
Plan: 2 to add, 0 to change, 2 to destroy.
Graph: planned 2 of 6, 4 unchanged and skipped, 2 refreshed, 4 not read.
The same data, reordered, replaced two resources keyed by position and touched none keyed by name. count in HCL has the first behaviour. for_each has the second. In pravo you choose by choosing the key.
Tip
Choose a key that names the thing: a zone, a service, a team. A position, or an attribute that may change, is a poor key.
Remove what you made.
Destroy complete! Resources: 0 added, 0 changed, 6 destroyed. [txn <id>]
B5 teaches this in full.
Break it
Return to the first directory. Give two zones the same name.
package estate
zones: [
{name: "eu-west-1a", cidr: "10.0.1.0/24"},
{name: "eu-west-1b", cidr: "10.0.2.0/24"},
{name: "eu-west-1a", cidr: "10.0.3.0/24"},
]
Error: evaluating .:
byName."eu-west-1a": conflicting values "10.0.3.0/24" and "10.0.1.0/24":
./estate.cue:9:9
./estate.cue:10:2
./estate.cue:11:13
./zones.cue:4:29
./zones.cue:6:29
Read the error from the top.
- The path is
byName."eu-west-1a". Two entries produced the same key. - The rule is a conflict (A3). The key is one field, and it cannot hold two cidrs.
- The first three locations are the comprehension in
estate.cuethat produced the key. The last two are the entries that disagree, lines 4 and 6 ofzones.cue.
A key taken from the data turns a duplicate into an error. With count, a duplicate is one more position.
Exercise
Subnets per zone
In this exercise the zones are a struct. Each key is a zone name. Replace zones.cue and estate.cue.
package estate
zones: {
"eu-west-1a": netnum: 1
"eu-west-1b": netnum: 2
"eu-west-1c": netnum: 3
}
package estate
subnets: {}
Generate subnets with one entry per zone. Use the zone name as the key of each entry. Each entry has zone and cidr. The cidr is 10.0.N.0/24, where N is the netnum of the zone.
Then reorder the lines of zones.cue. Every subnet must stay the same. The check does the reordering on a copy.
tmp=$(mktemp -d); cp estate.cue "$tmp/"
printf 'package estate\n\nzones: {\n\t"eu-west-1c": netnum: 3\n\t"eu-west-1b": netnum: 2\n\t"eu-west-1a": netnum: 1\n}\n' > "$tmp/zones.cue"
test "$(pravo eval -e 'len(subnets)' 2>/dev/null)" = 3 || { echo "subnets should hold three entries"; exit 1; }
test "$(pravo eval -e 'subnets["eu-west-1b"].cidr' 2>/dev/null)" = '"10.0.2.0/24"' || { echo "eu-west-1b should have 10.0.2.0/24"; exit 1; }
for k in eu-west-1a eu-west-1b eu-west-1c; do
test "$(pravo eval -e "subnets[\"$k\"]")" = "$(pravo -C "$tmp" eval -e "subnets[\"$k\"]")" || { echo "$k changed when zones.cue was reordered"; exit 1; }
done
rm -rf "$tmp"
echo ok
Run it on the start state. It fails.
subnets should hold three entries
Hint 1
for z, c in zones gives the key z and the value c. Write (z) as the key. Inside the string for the cidr, write \(c.netnum).
Hint 2
See comprehension. The key of the generated field is the only place where the zone name is needed.
Solution
package estate
subnets: {
for z, c in zones {
(z): {
zone: z
cidr: "10.0.\(c.netnum).0/24"
}
}
}
"eu-west-1a": {
zone: "eu-west-1a"
cidr: "10.0.1.0/24"
}
"eu-west-1b": {
zone: "eu-west-1b"
cidr: "10.0.2.0/24"
}
"eu-west-1c": {
zone: "eu-west-1c"
cidr: "10.0.3.0/24"
}
Recap
forbuilds fields from a list or a struct. The key you write names each field.- An address is the path of a field. pravo files a resource under its address, so a key that moves is a resource that is replaced.
- Name a field after the thing it describes, so that reordering the data changes nothing.
Check yourself
Which key survives a reorder of the list: "subnet\(i)" or (z.name)?
The second one. A zone name stays with its zone. A position moves to whatever entry now stands there.
What is an address?
The path of a field from the top of the specification, such as byName."eu-west-1a".
The next lesson gives a value a name, so you write it once.