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

Time
20 min
Level
intermediate
Needs
A13
You will learn
Can one specification serve three environments, with the strict rules of prod written once and checked before anything runs?
Done when
pravo eval -t env=prod prints three encrypted buckets, and pravo eval -t env=qa is refused.

View as Markdown

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.

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

Exercise

  1. 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 env prints "dev".
  • pravo eval -t env=prod -e env prints "prod".
  • pravo eval -t env=qa is 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.

Exercise

  1. Shape the buckets

Write shapes.cue for rules R2 and R3. Define #Bucket with these fields:

  • name, required. It starts with estate- and has 3 to 63 lowercase letters, digits or hyphens.
  • encrypted, a bool. The default is false.
  • retention, an integer from 1 to 365.
  • versioning, a bool. The default is false.
  • 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.

Hint 2

Read A5, A8, A9 and A13.

Solution

The file shapes.cue is in Your directory now.

Exercise

  1. Generate the subnets

Write network.cue for rules R4 and R5.

  • region is "eu-west-1".
  • #Subnet has a required zone and a required cidr. The cidr satisfies net.IPCIDR.
  • subnets has one entry per entry of zones. Its key is the region followed by the zone key, as in eu-west-1a. Its zone equals the key.
  • The cidr of zone number n is 10.0.n.0/24.
  • cidrs lists the CIDRs and holds at least one.
  • allow is cidrs as JSON text.

Expected result:

  • pravo eval -e subnets prints three entries, eu-west-1a to eu-west-1c, with the CIDRs 10.0.1.0/24 to 10.0.3.0/24.
  • pravo eval -e allow prints "[\"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.

Hint 2

Read A9, A10 and A13. json.Marshal produces the text for allow.

Solution

The file network.cue is in Your directory now.

Exercise

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

Hint 2

Read A11 for hidden fields, A9 for the pattern and A12 for a lookup by name.

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.

env.cue
package estate

env: *"dev" | "staging" | "prod" @tag(env)
shapes.cue
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)"}
network.cue
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)
rules.cue
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.

terminal
$pravo eval -e env
$pravo eval -t env=prod -e env
$pravo eval -e buckets.logs
"dev"
"prod"
name:       "estate-logs"
encrypted:  false
retention:  90
versioning: false
tags: {}
terminal
$pravo eval -e subnets
$pravo eval -e allow
"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\"]"
terminal
$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]

Now ask for an environment that does not exist.

terminalexit 6
$pravo eval -t env=qa
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

terminal
$pravo eval -t env=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.

data.cueunintended
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.

terminal
$for e in dev staging prod; do
$ if pravo eval -t env=$e >/dev/null 2>&1; then echo "$e: accepted"; else echo "$e: refused"; fi
$done
dev: accepted
staging: accepted
prod: refused

Read the refusal.

terminalexit 6
$pravo eval -t env=prod
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 >=90 is the rule.
  • The first location is the rule, in rules.cue. The second is the value, in data.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.

data.cue
package estate

buckets: {
	logs:    retention: 90
	assets:  retention: 365
	backups: retention: 180
}

zones: {a: 1, b: 2, c: 3}
terminal
$pravo eval -t env=prod -e buckets.backups.retention
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.

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

terminal
$echo "HCL: $(wc -l < main.tf | tr -d ' ') lines in 1 file"
$echo "CUE: $(cat *.cue | wc -l | tr -d ' ') lines in $(ls *.cue | wc -l | tr -d ' ') files"
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 prod sit 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.