§ A5 · Track A · CUE from zero

Constraints narrow a value

A rule in CUE is a value written on the field. Bounds and regular expressions narrow what the field may hold.

Time
9 min
Level
beginner
Needs
A4
You will learn
How do you restrict a value to a range or to text of a given shape?
Done when
pravo eval rejects a port of 70000 and a bucket name of Logs. It prints the data when the values are inside their bounds.

View as Markdown

On this page

In HCL, every rule is a validation block with its own message. The value arrives from another file, and OpenTofu tests it when you plan.

variables.tf
variable "bucket_name" {
  type = string
  validation {
    condition     = can(regex("^[a-z0-9-]{3,63}$", var.bucket_name))
    error_message = "Use 3 to 63 lowercase letters, digits or hyphens."
  }
}

variable "port" {
  type = number
  validation {
    condition     = var.port >= 1 && var.port <= 65535
    error_message = "Use a port from 1 to 65535."
  }
}

The same two rules in CUE are two fields.

the same two rules
rules: https: port: int & >=1 & <=65535
buckets: audit: name: =~"^[a-z0-9-]{3,63}$"

The HCL takes 15 lines and the CUE takes 2. In CUE the rule is a value that you write on the field, next to the value it judges. The CUE has no error_message: pravo builds the message from the rule that failed, as the next sections show.

The operator & joins two values into one value that satisfies both. >=1, <=65535 and =~"^[a-z0-9-]{3,63}$" are values too. Each one is a bound: a value that allows only some numbers or some text. A field that carries a bound allows only what the bound allows.

Run the four values

The widget shows results that pravo eval -e printed. The flag evaluates an expression inside a package. The runs below use an empty package in a directory of its own. The two numbers at the end of each location are the line and the column in the expression. They point at the bound or the type that rejected the value, then at the value.

probe/probe.cue
package estate
terminalexit 1
$pravo -C probe eval -e 'int & >=1 & <=10 & 3'
$pravo -C probe eval -e 'int & >=1 & <=10 & 0'
$pravo -C probe eval -e 'int & >=1 & <=10 & 11'
$pravo -C probe eval -e 'int & >=1 & <=10 & "3"'
3

Error: -e "int & >=1 & <=10 & 0": invalid value 0 (out of bound >=1):
    1:7
    1:20


Error: -e "int & >=1 & <=10 & 11": invalid value 11 (out of bound <=10):
    1:13
    1:20


Error: -e "int & >=1 & <=10 & \"3\"": conflicting values int and "3" (mismatched types int and string):
    1:1
    1:20

Warm-up

From earlier lessons

From A2: what does pravo eval -c do?

It fails unless every value is concrete. A constraint such as int or >=1 is not concrete.

From A3: two files give retention_days the values 30 and 90. What does pravo print?

A conflict that names the path, the two values and both locations. It prints no data.

Start from the estate

Work in the directory from A4. If you start here, write these three files.

buckets.cue
package estate

buckets: {
	logs: {
		name:           "logs"
		versioning:     true // keep old versions
		encrypted:      true
		retention_days: 365
		tags: owner: "platform"
	}
	uploads: {
		name:           "uploads"
		versioning:     false
		encrypted:      true
		retention_days: 30
		tags: owner: "web"
	}
}
rules.cue
package estate

rules: {
	https: {
		port:     443
		protocol: "tcp"
	}
	ssh: {
		port:     22
		protocol: "tcp"
	}
}
team.cue
package estate

buckets: uploads: tags: cost_center: "4711"

buckets: uploads: tags: env: "prod"

Save the output.

terminal
$pravo eval > before.txt

A bound on a number

The platform team limits every port to the integers from 1 to 65535. It keeps its limits in a file of its own.

limits.cuevalid
package estate

rules: https: port: int & >=1 & <=65535

rules: ssh: port: int & >=1 & <=65535

Both ports are inside the bound, so the data does not change.

terminal
$pravo eval | diff before.txt - && echo same
same

A bound changes nothing for a value that meets it. It matters when a value does not.

Break it

Someone sets the port of the ssh rule to 70000. Replace rules.cue.

rules.cuerejected
package estate

rules: {
	https: {
		port:     443
		protocol: "tcp"
	}
	ssh: {
		port:     70000
		protocol: "tcp"
	}
}
terminalexit 6
$pravo eval
Error: evaluating .:
rules.ssh.port: invalid value 70000 (out of bound <=65535):
    ./limits.cue:5:31
    ./rules.cue:9:13

Read the error from left to right.

  • Path. rules.ssh.port is the field that failed.
  • Message. invalid value 70000 (out of bound <=65535) names the value and the bound it breaks. The bound is named, so you know which of >=1 and <=65535 rejected it.
  • Locations. The first location is the bound that rejected the value: limits.cue, line 5, column 31. The second is the value: rules.cue, line 9.

Put the port back.

rules.cuevalid
package estate

rules: {
	https: {
		port:     443
		protocol: "tcp"
	}
	ssh: {
		port:     22
		protocol: "tcp"
	}
}

A bound is not a type

A bound allows every number in its range. Write a scratch file with a bound and a value of 22.5.

float/f.cueunintended
package float

port: >=1 & <=65535
port: 22.5
terminal
$pravo -C float eval
port: 22.5

The file is valid and the value is not a port. Add int.

float/f.cuerejected
package float

port: int & >=1 & <=65535
port: 22.5
terminalexit 6
$pravo -C float eval
Error: evaluating ./float:
port: conflicting values int and 22.5 (mismatched types int and float):
    ./float/f.cue:3:7
    ./float/f.cue:4:7

The message names both sides of the clash: the type int and the value 22.5. The reason follows in brackets, mismatched types int and float. This is why the limits above start with int &.

Bounds on text

A regular expression is a bound too. The operator =~ accepts only text that matches it. Write a scratch file with a bucket name that does not match.

names/n.cuerejected
package names

name: =~"^[a-z0-9-]{3,63}$"
name: "Logs_Bucket"
terminalexit 6
$pravo -C names eval
Error: evaluating ./names:
name: invalid value "Logs_Bucket" (out of bound =~"^[a-z0-9-]{3,63}$"):
    ./names/n.cue:3:7
    ./names/n.cue:4:7

The message reads as it did for the port. The bound is named, with its regular expression, and the first location is the bound. The expression means 3 to 63 characters, each a lowercase letter, a digit or a hyphen.

Bound Allows A value that passes
>=1 numbers of at least 1 5
>4 numbers above 4 5
<=5 numbers of at most 5 5
<6 numbers below 6 5
!=4 anything except 4 5
=~"^[a-z][0-9]$" text that matches "a1"
!~"^b" text that does not match "a1"
Run each bound
terminal
$pravo eval -e '5 & >=1'
$pravo eval -e '5 & >4'
$pravo eval -e '5 & <=5'
$pravo eval -e '5 & <6'
$pravo eval -e '5 & !=4'
$pravo eval -e '"a1" & =~"^[a-z][0-9]$"'
$pravo eval -e '"a1" & !~"^b"'
5
5
5
5
5
"a1"
"a1"

The two rules for ports repeat. Every bucket name needs the same rule. A8 and A9 write each rule once.

Exercise

Exercise

Write the bucket-name rule

A third bucket, audit, has no name yet. Its name must follow the rule from the HCL at the top of this page.

audit.cue
package estate

buckets: audit: {
	name:       string
	versioning: true
}

Add one line to limits.cue that bounds buckets.audit.name. The check joins three valid and three invalid names to that field with &.

check.sh
fixture() {
  if pravo eval -e "buckets.audit.name & \"$1\"" > /dev/null 2>&1; then got=valid; else got=invalid; fi
  [ "$got" = "$2" ] || { echo "$1 should be $2"; exit 1; }
}
fixture logs valid
fixture my-bucket-01 valid
fixture abc valid
fixture ab invalid
fixture Logs invalid
fixture my_bucket invalid
echo ok

Run the check on the start state. It fails.

terminalexit 1
$sh check.sh
ab should be invalid
Hint 1

Write the bound with =~ and a regular expression. A name has 3 to 63 characters, each a lowercase letter, a digit or a hyphen. Add the line to limits.cue.

Hint 2

The table in Bounds on text lists =~. See bound.

Solution
limits.cue
package estate

rules: https: port: int & >=1 & <=65535

rules: ssh: port: int & >=1 & <=65535

buckets: audit: name: =~"^[a-z0-9-]{3,63}$"

Recap

  • & joins two values into one that satisfies both. A bound such as >=1 is a value, so a rule is a value written on the field.
  • A bound that rejects a value is named in the error. The first location is the bound, and the second is the value.
  • A numeric bound allows decimals. Add int when the field is a whole number.

Check yourself

What do the two locations in invalid value 70000 (out of bound <=65535) point to?

The first is the bound that rejected the value. The second is the value.

Does >=1 & <=65535 reject 22.5?

No. A numeric bound allows every number in its range. int & >=1 & <=65535 rejects it.

The next lesson offers choices and a default.