§ A13 · Track A · CUE from zero
Packages and the standard library
Import validators and functions from the standard library, and share a definition between directories with your own package.
On this page
HCL's built-in functions need no import. You call them anywhere. To check a value, you put a function call in a validation block.
variable "cidr" {
type = string
validation {
condition = can(cidrhost(var.cidr, 0))
error_message = "cidr must be a network in CIDR notation."
}
}
locals {
subnet = cidrsubnet(var.cidr, 8, 1)
name = upper(var.name)
policy = jsonencode({ cidrs = [local.subnet] })
}
To share a definition between directories, HCL uses a module with its own variables and outputs.
In CUE, a check is a value you join to the field. A function is a name in a package. A package is a directory of .cue files that share a package clause. You have written one since A1. An import makes another package available under its last name. After import "strings", you write strings.ToUpper.
The standard library ships with pravo. Your own directories can be packages too.
Warm-up
From earlier lessons
A comprehension uses the zone name as the key, and two zones have the same name. What does pravo eval print?
A conflict at that key, with the locations of both entries (A10).
A file has a let, a hidden field and a regular field that reads both. What does pravo eval print?
Only the regular field. A let and a hidden field stay out of the output (A11).
Check values with validators
Many names in the standard library are validators. A validator is a value that accepts or refuses, and you combine it with & like int or >=1 (A5). The value being checked is its first argument, so you write the others.
Work in an empty directory. Write estate.cue.
package estate
import (
"list"
"net"
"strings"
)
subnet: {
zone: strings.HasPrefix("eu-") & "eu-west-1a"
cidr: net.IPCIDR & "10.0.1.0/24"
}
zones: list.MinItems(1) & ["a", "b"]
subnet: {
zone: "eu-west-1a"
cidr: "10.0.1.0/24"
}
zones: ["a", "b"]
net.IPCIDR accepts a network in CIDR notation. strings.HasPrefix("eu-") accepts a string that starts with eu-. list.MinItems(1) accepts a list with at least one item.
A file imports only what it uses. Each file lists its own imports.
Compute values with functions
The other names are functions. A function takes values and returns a value. Add three fields to the file. The block shows the whole file.
package estate
import (
"encoding/json"
"list"
"net"
"strings"
)
subnet: {
zone: strings.HasPrefix("eu-") & "eu-west-1a"
cidr: net.IPCIDR & "10.0.1.0/24"
}
zones: list.MinItems(1) & ["a", "b"]
label: strings.ToUpper(subnet.zone)
total: list.Sum([30, 60, 90])
policy: json.Marshal({cidrs: [subnet.cidr]})
subnet: {
zone: "eu-west-1a"
cidr: "10.0.1.0/24"
}
zones: ["a", "b"]
label: "EU-WEST-1A"
total: 180
policy: "{\"cidrs\":[\"10.0.1.0/24\"]}"
json.Marshal turns a value into JSON text. The result is an ordinary string, so pravo prints its quotes escaped. json.Unmarshal does the reverse.
Try a function without a file
pravo eval -e accepts standard library names without an import. It works as a calculator. These rows pair common HCL functions with their CUE side.
| HCL | CUE |
|---|---|
cidrsubnet("10.0.0.0/16", 8, n) |
net.AddIPCIDR("10.0.0.0/24", n) |
contains(list, x) |
list.Contains(list, x) |
upper(s) |
strings.ToUpper(s) |
join("-", list) |
strings.Join(list, "-") |
jsonencode(v) |
json.Marshal(v) |
jsondecode(s) |
json.Unmarshal(s) |
length(list) |
len(list) |
can(regex(p, s)) |
regexp.Match(p, s) |
join and strings.Join take their arguments in a different order. net.AddIPCIDR adds n whole subnets of the size you give it.
"10.0.3.0/24"
true
"LOGS"
"a-b"
"{\"a\":1}"
a: 1
3
true
The function map lists every HCL function.
| Package | Holds | Names on this page |
|---|---|---|
strings |
text functions and checks | HasPrefix, ToUpper, Join |
list |
list functions and checks | MinItems, Sum, Contains |
net |
address checks and address arithmetic | IPCIDR, AddIPCIDR |
encoding/json |
JSON text | Marshal, Unmarshal |
regexp |
regular expression matching | Match |
More names to try
The standard library has many more. Each line below runs as written.
["a", "b", "c"]
"logs"
"a_b_c"
9
["a", "b"]
[1, 2, 3]
true
"10.0.0.2"
false
1024
42
"c.txt"
"aGk="
The packages math, strconv, path, encoding/yaml, encoding/base64 and time ship with pravo as well.
Share a definition in your own package
A directory below the module root is a package of its own. To import it, the root needs a file cue.mod/module.cue. Its module line is the prefix of every import path.
pravo init writes that file and names the module after the directory, as in pravo.local/estate. This lesson writes the file itself, so the import path is the same on every machine.
module: "estate.example"
language: version: "v0.14.0"
Put the definition in a directory platform. Its package clause is the directory name.
package platform
import "net"
#Subnet: {
zone!: string
cidr!: net.IPCIDR
}
Replace estate.cue. It imports the package by module path and directory.
package estate
import "estate.example/platform"
subnets: {
a: platform.#Subnet & {zone: "eu-west-1a", cidr: "10.0.1.0/24"}
b: platform.#Subnet & {zone: "eu-west-1b", cidr: "10.0.2.0/24"}
}
./cue.mod/module.cue
./estate.cue
./platform/shapes.cue
subnets: {
a: {
zone: "eu-west-1a"
cidr: "10.0.1.0/24"
}
b: {
zone: "eu-west-1b"
cidr: "10.0.2.0/24"
}
}
A definition is visible from other packages, and so is a regular field. A hidden field is not.
Break it
Give the second subnet a prefix length of 33. An IPv4 prefix has at most 32 bits.
package estate
import "estate.example/platform"
subnets: {
a: platform.#Subnet & {zone: "eu-west-1a", cidr: "10.0.1.0/24"}
b: platform.#Subnet & {zone: "eu-west-1b", cidr: "10.0.2.0/33"}
}
Error: evaluating .:
subnets.b.cidr: invalid value "10.0.2.0/33" (does not satisfy net.IPCIDR): netip.ParsePrefix("10.0.2.0/33"): prefix length out of range:
./estate.cue:7:51
./platform/shapes.cue:7:9
Read the error from the top.
- The path is
subnets.b.cidr. - The message names the value,
"10.0.2.0/33", and the rule it fails:net.IPCIDR. - The reason follows:
prefix length out of range. - The first location is the value, in
estate.cue. The second is the rule, inplatform/shapes.cue.
The error points into two files, in two packages. You do not search for the rule.
Restore the valid subnet.
package estate
import "estate.example/platform"
subnets: {
a: platform.#Subnet & {zone: "eu-west-1a", cidr: "10.0.1.0/24"}
b: platform.#Subnet & {zone: "eu-west-1b", cidr: "10.0.2.0/24"}
}
package estate
import "strings"
x: 1
Predict A second file imports strings and never uses it. Is that accepted?
No. An import that nothing uses is an error.
Error: evaluating .:
imported and not used: "strings":
./unused.cue:3:8
Remove the file. The exercise starts from a clean directory.
Exercise
A CIDR check on every subnet
Start from three subnets in one file.
package estate
subnets: {
a: {zone: "eu-west-1a", cidr: "10.0.1.0/24"}
b: {zone: "eu-west-1b", cidr: "10.0.2.0/24"}
c: {zone: "eu-west-1c", cidr: "10.0.3.0/24"}
}
Add one line so that every present and future subnet must have a cidr that satisfies net.IPCIDR. The check adds a fourth subnet with a bad address to a copy of the directory and expects a refusal. It also adds one with a good address and expects it to pass.
tmp=$(mktemp -d); cp estate.cue "$tmp/"
pravo eval > /dev/null 2>&1 || { echo "the three subnets should pass"; exit 1; }
printf 'package estate\n\nsubnets: d: {zone: "x", cidr: "10.0.4.0/33"}\n' > "$tmp/fixture.cue"
if pravo -C "$tmp" eval > /dev/null 2>&1; then echo "a bad cidr should be refused"; exit 1; fi
printf 'package estate\n\nsubnets: d: {zone: "x", cidr: "10.0.4.0/24"}\n' > "$tmp/fixture.cue"
pravo -C "$tmp" eval > /dev/null 2>&1 || { echo "a good cidr should pass"; exit 1; }
rm -rf "$tmp"
echo ok
Run it on the start state. It fails.
a bad cidr should be refused
Hint 1
A pattern applies a constraint to every entry of a struct. Import net at the top of the file.
Hint 2
Read A9. The pattern is [string]: ... and the constraint goes after the field name.
Solution
package estate
import "net"
subnets: [string]: cidr: net.IPCIDR
subnets: {
a: {zone: "eu-west-1a", cidr: "10.0.1.0/24"}
b: {zone: "eu-west-1b", cidr: "10.0.2.0/24"}
c: {zone: "eu-west-1c", cidr: "10.0.3.0/24"}
}
A bad address in a second file is refused, and the error points at both files.
Error: evaluating .:
subnets.d.cidr: invalid value "10.0.4.0/33" (does not satisfy net.IPCIDR): netip.ParsePrefix("10.0.4.0/33"): prefix length out of range:
./estate.cue:5:26
./fixture.cue:3:31
Recap
import "name"brings in a package. A validator joins a value with&. A function computes a value.pravo eval -eaccepts standard library names without an import.- A directory below the module root is a package. A definition in it is checked wherever it is used.
Check yourself
Which argument of a validator do you leave out when you write it?
The first one. The value being checked is supplied by &, so strings.HasPrefix("eu-") & x checks x.
What happens to an import that no line uses?
The file is refused with imported and not used.
The next lesson uses all of it in one specification.