§ A14 · Track A · CUE from zero
Project, three environments from one definition
Write one specification from a brief. It runs in dev, staging and prod, is stricter in prod, and refuses every other environment.
On this page
Track A in one specification. You get a brief, four tasks with the output each must produce, and two hints per task. The reference files come after the tasks. Write your own first.
The brief
You write a small estate as data and constraints. There is no provider and no cloud. The estate has three buckets, three availability zones and three environments. Work in an empty directory.
Everything the specification must do is in this table.
| Rule | The specification must |
|---|---|
| R1 | Offer the environments dev, staging and prod. dev is the default. Any other value is refused. |
| R2 | Name each bucket estate- plus its key. A name has 3 to 63 lowercase letters, digits or hyphens. |
| R3 | Give each bucket retention of 1 to 365 days, encrypted and versioning (both default false) and string tags. |
| R4 | Give each zone one subnet in 10.0.n.0/24, named after the region and the zone. Every CIDR is valid. At least one exists. |
| R5 | Make allow the list of subnet CIDRs as JSON text. |
| R6 | Require staging to keep 30 days or more. Require prod to keep 90 days or more and to encrypt every bucket. dev adds no rule. |
The platform team sends the facts. Save them as data.cue.
package estate
buckets: {
logs: retention: 90
assets: retention: 365
backups: retention: 180
}
zones: {a: 1, b: 2, c: 3}
You write four more files, one per task. Each task gives the names to use and a result to compare with. Two hints come before the answer.
Tasks
- Select the environment
Write env.cue. It declares the field env for rule R1. The command line supplies the value with -t env=....
Expected result:
pravo eval -e envprints"dev".pravo eval -t env=prod -e envprints"prod".pravo eval -t env=qais refused.
Hint 1
Mark dev as the default with *. End the line with @tag(env).
Hint 2
Read A6. The whole file is the package clause and one field.
Solution
The file env.cue is in Your directory now.
- Shape the buckets
Write shapes.cue for rules R2 and R3. Define #Bucket with these fields:
name, required. It starts withestate-and has 3 to 63 lowercase letters, digits or hyphens.encrypted, a bool. The default isfalse.retention, an integer from 1 to 365.versioning, a bool. The default isfalse.tags, a struct of strings.
Then apply #Bucket to every entry of buckets, and set each name to estate- plus the key.
Expected result: pravo eval -e buckets.logs prints these five fields.
name: "estate-logs"
encrypted: false
retention: 90
versioning: false
tags: {}
Hint 1
Join the checks with &. Use strings.HasPrefix for the prefix and =~ for the characters. A pattern [Name=string] applies to every entry and names the key.
Solution
The file shapes.cue is in Your directory now.
- Generate the subnets
Write network.cue for rules R4 and R5.
regionis"eu-west-1".#Subnethas a requiredzoneand a requiredcidr. Thecidrsatisfiesnet.IPCIDR.subnetshas one entry per entry ofzones. Its key is the region followed by the zone key, as ineu-west-1a. Itszoneequals the key.- The
cidrof zone number n is10.0.n.0/24. cidrslists the CIDRs and holds at least one.allowiscidrsas JSON text.
Expected result:
pravo eval -e subnetsprints three entries,eu-west-1atoeu-west-1c, with the CIDRs10.0.1.0/24to10.0.3.0/24.pravo eval -e allowprints"[\"10.0.1.0/24\",\"10.0.2.0/24\",\"10.0.3.0/24\"]".
Hint 1
A comprehension builds one entry per zone. A pattern subnets: [Name=string]: #Subnet & {zone: Name} sets each zone from its key. net.AddIPCIDR("10.0.0.0/24", n) is the n-th /24.
Solution
The file network.cue is in Your directory now.
- Make prod stricter
Write rules.cue for rule R6. Keep the three sets of rules in one hidden struct with a key per environment. Apply the set that env selects to every bucket.
Expected result: this loop prints one line per environment.
for e in dev staging prod; do echo "$e: $(pravo eval -t env=$e -e '[for b in buckets {b.encrypted}]')"; done
dev: [false, false, false]
staging: [false, false, false]
prod: [true, true, true]
Hint 1
A hidden struct holds the rules: _rules: {dev: ..., staging: ..., prod: ...}. The pattern buckets: [string]: _rules[env] adds the chosen set to every bucket.
Solution
The file rules.cue is in Your directory now.
Your directory now
Compare your files with these. Each tab is one file. data.cue is the one above. Open the tabs after you have tried the tasks.
package estate
env: *"dev" | "staging" | "prod" @tag(env)
package estate
import "strings"
#Bucket: {
name!: string & strings.HasPrefix("estate-") & =~"^[a-z0-9-]{3,63}$"
encrypted: *false | bool
retention: int & >=1 & <=365
versioning: *false | bool
tags: [string]: string
}
buckets: [Name=string]: #Bucket & {name: "estate-\(Name)"}
package estate
import (
"encoding/json"
"list"
"net"
)
#Subnet: {
zone!: string
cidr!: net.IPCIDR
}
region: "eu-west-1"
subnets: [Name=string]: #Subnet & {zone: Name}
subnets: {
for z, n in zones {
"\(region)\(z)": cidr: net.AddIPCIDR("10.0.0.0/24", n)
}
}
cidrs: list.MinItems(1) & [for s in subnets {s.cidr}]
allow: json.Marshal(cidrs)
package estate
_rules: {
dev: {}
staging: {retention: >=30}
prod: {encrypted: true, retention: >=90}
}
buckets: [string]: _rules[env]
The rules for all three environments live in one place. The pattern in shapes.cue and the pattern in rules.cue both apply to every bucket. Unification merges them, and the files may be read in any order.
Check your work
Run the expected results of tasks 1 to 4.
"dev"
"prod"
name: "estate-logs"
encrypted: false
retention: 90
versioning: false
tags: {}
"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"
}
"[\"10.0.1.0/24\",\"10.0.2.0/24\",\"10.0.3.0/24\"]"
dev: [false, false, false]
staging: [false, false, false]
prod: [true, true, true]
Now ask for an environment that does not exist.
Error: evaluating .:
env: 3 errors in empty disjunction:
env: conflicting values "dev" and "qa":
./env.cue:3:7
env: conflicting values "prod" and "qa":
./env.cue:3:27
env: conflicting values "staging" and "qa":
./env.cue:3:15
The refusal comes from env.cue. -t env=qa meets the three choices, and none matches. The refusal happens during evaluation, before any provider starts.
Run prod
buckets: {
logs: {
name: "estate-logs"
encrypted: true
retention: 90
versioning: false
tags: {}
}
assets: {
name: "estate-assets"
encrypted: true
retention: 365
versioning: false
tags: {}
}
backups: {
name: "estate-backups"
encrypted: true
retention: 180
versioning: false
tags: {}
}
}
env: "prod"
...
zones: {
a: 1
b: 2
c: 3
}
All three buckets are encrypted. Nothing in data.cue says so. The rule for prod adds encrypted: true to every bucket, and the default false gives way.
Break prod
Write the changed facts.
package estate
buckets: {
logs: retention: 90
assets: retention: 365
backups: retention: 30
}
zones: {a: 1, b: 2, c: 3}
Predict Change the retention of backups from 180 to 30 days. Which of dev, staging and prod refuse the estate?
Only prod. The rule for staging allows 30 days. The rule for dev allows any value from 1 to 365.
dev: accepted
staging: accepted
prod: refused
Read the refusal.
Error: evaluating .:
buckets.backups.retention: invalid value 30 (out of bound >=90):
./rules.cue:6:40
./data.cue:6:22
Read the error from the top.
- The path is
buckets.backups.retention. - The message is
invalid value 30 (out of bound >=90). The bound>=90is the rule. - The first location is the rule, in
rules.cue. The second is the value, indata.cue.
The error names the bucket, the number and the line in each file. The fix is a decision for the platform team: raise the retention or change the rule. Raise the retention.
package estate
buckets: {
logs: retention: 90
assets: retention: 365
backups: retention: 180
}
zones: {a: 1, b: 2, c: 3}
180
The same estate in HCL
This is an HCL version of the same specification. It is shown for the line count and is not run on this page.
variable "env" {
type = string
default = "dev"
validation {
condition = contains(["dev", "staging", "prod"], var.env)
error_message = "env must be dev, staging or prod."
}
}
locals {
region = "eu-west-1"
zones = { a = 1, b = 2, c = 3 }
retention = {
logs = 90
assets = 365
backups = 180
}
rules = {
dev = { encrypted = false, min_retention = 1 }
staging = { encrypted = false, min_retention = 30 }
prod = { encrypted = true, min_retention = 90 }
}
rule = local.rules[var.env]
buckets = {
for k, days in local.retention : k => {
name = "estate-${k}"
encrypted = local.rule.encrypted
retention = days
versioning = false
tags = {}
}
}
subnets = {
for z, n in local.zones : "${local.region}${z}" => {
zone = "${local.region}${z}"
cidr = cidrsubnet("10.0.0.0/16", 8, n)
}
}
cidrs = [for s in local.subnets : s.cidr]
}
output "buckets" {
value = local.buckets
precondition {
condition = alltrue([for b in local.buckets : can(regex("^[a-z0-9-]{3,63}$", b.name))])
error_message = "Bucket names must be 3 to 63 lowercase letters, digits or hyphens."
}
precondition {
condition = alltrue([for b in local.buckets : b.retention >= local.rule.min_retention && b.retention <= 365])
error_message = "Retention must be at least ${local.rule.min_retention} days and at most 365 in ${var.env}."
}
}
output "subnets" {
value = local.subnets
precondition {
condition = length(local.cidrs) >= 1
error_message = "At least one subnet is required."
}
}
output "allow" {
value = jsonencode(local.cidrs)
}
Count the lines of both. Blank lines and package clauses are included. The data is part of both counts.
HCL: 73 lines in 1 file
CUE: 59 lines in 5 files
The counts are close. The difference is where the rules sit. In CUE each rule is a value on the field it judges, and the same definitions apply to every bucket. In HCL the rules are conditions in precondition blocks, written apart from the data they check.
What you used
| Part | Constructs | Lesson |
|---|---|---|
env.cue |
A choice, a default and a tag | A6 |
#Bucket |
A definition, bounds, a match, strings.HasPrefix |
A5, A8, A13 |
buckets rule |
A pattern that names the key | A9 |
subnets |
A comprehension, a pattern, net.IPCIDR, net.AddIPCIDR |
A10, A13 |
_rules |
A hidden field, a lookup by key, a pattern | A11, A12 |
cidrs and allow |
list.MinItems, json.Marshal |
A13 |
Recap
- Constraints from several files unify. The strict rules of
prodsit in one file and apply to every bucket. - A value that breaks a rule is refused. The error gives the path, the value, the rule and a location in each file.
-t env=...selects the environment, and a value outside the choices is refused.
Check yourself
Why does pravo eval -t env=prod print encrypted: true for a bucket that never says so?
The rule for prod is encrypted: true. It unifies with the default false, and the default gives way to the concrete value.
Why does the error for backups point at two files?
The bound >=90 is written in rules.cue and the value 30 in data.cue. Both took part in the conflict.