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

Time
12 min
Level
intermediate
Needs
A12
You will learn
Which checks and functions does the standard library offer? How does one directory use a definition from another?
Done when
pravo eval accepts the CIDR 10.0.1.0/24 and refuses 10.0.2.0/33.

View as Markdown

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.

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

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"]
terminal
$pravo eval
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.

estate.cue
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]})
terminal
$pravo eval
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.

terminal
$pravo eval -e 'net.AddIPCIDR("10.0.0.0/24", 3)'
$pravo eval -e 'list.Contains(["a", "b"], "b")'
$pravo eval -e 'strings.ToUpper("logs")'
$pravo eval -e 'strings.Join(["a", "b"], "-")'
$pravo eval -e 'json.Marshal({a: 1})'
$pravo eval -e 'json.Unmarshal("{\"a\": 1}")'
$pravo eval -e 'len([1, 2, 3])'
$pravo eval -e 'regexp.Match("^eu-", "eu-west-1")'
"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.

terminal
$pravo eval -e 'strings.Split("a,b,c", ",")'
$pravo eval -e 'strings.TrimPrefix("estate-logs", "estate-")'
$pravo eval -e 'strings.Replace("a-b-c", "-", "_", -1)'
$pravo eval -e 'list.Max([3, 9, 4])'
$pravo eval -e 'list.SortStrings(["b", "a"])'
$pravo eval -e 'list.Concat([[1], [2, 3]])'
$pravo eval -e 'net.InCIDR("10.0.1.5", "10.0.0.0/16")'
$pravo eval -e 'net.IPv4 & "10.0.0.2"'
$pravo eval -e 'json.Valid("{")'
$pravo eval -e 'math.Pow(2, 10)'
$pravo eval -e 'strconv.Atoi("42")'
$pravo eval -e 'path.Base("/a/b/c.txt")'
$pravo eval -e 'base64.Encode(null, "hi")'
["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.

cue.mod/module.cue
module: "estate.example"
language: version: "v0.14.0"

Put the definition in a directory platform. Its package clause is the directory name.

platform/shapes.cue
package platform

import "net"

#Subnet: {
	zone!: string
	cidr!: net.IPCIDR
}

Replace estate.cue. It imports the package by module path and directory.

estate.cue
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"}
}
terminal
$find . -name '*.cue' | sort
$pravo eval
./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.

estate.cuerejected
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"}
}
terminalexit 6
$pravo eval
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, in platform/shapes.cue.

The error points into two files, in two packages. You do not search for the rule.

Restore the valid subnet.

estate.cue
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"}
}
unused.cue
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.

terminalexit 6
$pravo eval
Error: evaluating .:
imported and not used: "strings":
    ./unused.cue:3:8

Remove the file. The exercise starts from a clean directory.

terminal
$rm unused.cue

Exercise

Exercise

A CIDR check on every subnet

Start from three subnets in one file.

estate.cue
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.

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

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

terminalexit 6
$printf 'package estate\n\nsubnets: d: {zone: "x", cidr: "10.0.4.0/33"}\n' > fixture.cue
$pravo eval -e subnets.d
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 -e accepts 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.