§ 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.

Time
12 min
Level
intermediate
Needs
A9
You will learn
How does for generate fields from data? Why does the key you give each field matter?
Done when
A swap of two zones replaces the two resources keyed by position and none keyed by zone name. pravo eval -e subnets prints one subnet per zone.

View as Markdown

On this page

In HCL, count creates one resource per list entry. The position of the entry becomes the identity of the resource.

main.tf
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.

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.

estate.cue
package estate

byIndex: {
	for i, z in zones {
		"subnet\(i)": z.cidr
	}
}

byName: {
	for z in zones {
		(z.name): z.cidr
	}
}
terminal
$pravo eval -e byIndex
subnet0: "10.0.1.0/24"
subnet1: "10.0.2.0/24"
subnet2: "10.0.3.0/24"
terminal
$pravo eval -e byName
"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.

zones.cue
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"},
]
terminal
$pravo eval -e byIndex
subnet0: "10.0.2.0/24"
subnet1: "10.0.1.0/24"
subnet2: "10.0.3.0/24"
terminal
$pravo eval -e byName
"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.

plan/zones.cue
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"},
]
plan/main.cue
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
		}
	}
}
terminal
$pravo -C plan init
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.
terminal
$pravo -C plan apply -auto-approve | grep '^Apply complete'
$pravo -C plan state list
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.

plan/zones.cue
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.

terminal
$pravo -C plan plan | grep -E '^ #|^Plan:|^Graph:'
  # 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.

terminal
$pravo -C plan destroy -auto-approve | grep '^Destroy complete'
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.

zones.cueconflict
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"},
]
terminalexit 6
$pravo eval
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.cue that produced the key. The last two are the entries that disagree, lines 4 and 6 of zones.cue.

A key taken from the data turns a duplicate into an error. With count, a duplicate is one more position.

Exercise

Exercise

Subnets per zone

In this exercise the zones are a struct. Each key is a zone name. Replace zones.cue and estate.cue.

zones.cue
package estate

zones: {
	"eu-west-1a": netnum: 1
	"eu-west-1b": netnum: 2
	"eu-west-1c": netnum: 3
}
estate.cue
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.

check.sh
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.

terminalexit 1
$sh check.sh
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
estate.cue
package estate

subnets: {
	for z, c in zones {
		(z): {
			zone: z
			cidr: "10.0.\(c.netnum).0/24"
		}
	}
}
terminal
$pravo eval -e subnets
"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

  • for builds 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.