§ B4 · Track B · pravo
Inputs, tags, environments
Run one specification as dev, staging and prod. Use a constrained field, a tag and a state path derived from it.
On this page
Warm-up
Two questions from earlier lessons. Decide, then open the answer.
Predict The only line about env is env: *"dev" | "prod". What does pravo eval -e env print?
"dev". The * marks the default, and nothing else sets the field (A6).
Predict What exit status does pravo plan --detailed-exitcode return when nothing changes?
- It returns 2 when the plan has changes (B3).
The problem
In OpenTofu the environment is a stored setting. tofu workspace select prod stores your choice in the directory, and every later tofu apply there reads it. The command line does not name the environment. If you open a terminal next week and run tofu apply, it goes to the workspace you selected last.
A variable with a validation block can list the allowed environment names, but nothing connects it to the workspace. terraform.workspace only reports the name in force.
The idea
pravo has no workspaces. Select the environment with -t env=prod, on every command. The name is one field, the restriction is part of the field, and the state path is computed from it.
variable "env" {
type = string
default = "dev"
validation {
condition = contains(["dev", "prod"], var.env)
error_message = "env must be dev or prod."
}
}
env: *"dev" | "prod" @tag(env)
The field has three parts. The disjunction "dev" | "prod" lists the allowed values, as in A6. The * marks "dev" as the default. The attribute @tag(env) lets -t env=... fill the field. A tag here is an input to the specification. It is not a label on a cloud resource.
Write the specification
Work in an empty directory. Five short files. main.cue holds the environment and the state path. sizes.cue holds what differs between environments, as data.
package estate
import (
"pravo.dev/pravo"
rnd "pravo.dev/providers/hashicorp/random"
loc "pravo.dev/providers/hashicorp/local"
)
$random: rnd.#Provider
$local: loc.#Provider
// The environment: dev unless -t env=... says otherwise.
env: *"dev" | "prod" @tag(env)
// One state directory per environment.
$settings: pravo.#Settings & {state: path: "state/\(env)"}
package estate
// What differs between environments.
sizes: {dev: 1, prod: 3}
// The row of the environment that is selected.
size: sizes[env]
package estate
port: $random.#Resource.random_integer & {
min: 20000
max: 29999
seed: env
}
package estate
web: $local.#Resource.local_file & {
filename: "\(env).conf"
content: "env = \(env)\nport = \(port.result)\nworkers = \(size)\n"
}
package estate
import "pravo.dev/pravo"
web_file: pravo.#Output & {value: web.filename}
web_port: pravo.#Output & {value: port.result}
$settings is the block of pravo's own settings for the root. pravo.#Settings is a generated definition, so a misspelt setting is field not allowed, as for a resource. Its state.path field says where the state lives. The default is pravo.state. Here it depends on env.
Configuration does not load yet; generating schemas for the declared providers first.
Created cue.mod/module.cue
Providers:
...
pravo has been initialised. Run "pravo plan" to see what it would do.
Select an environment
pravo eval -e prints one expression. Ask for the environment and its size with each tag:
"dev"
"prod"
1
3
Without a tag, env takes its default. With -t env=prod, the field is "prod", and size reads the row of that name.
Apply two environments
Apply complete! Resources: 2 added, 0 changed, 0 destroyed. [txn <id>]
Outputs:
web_file: "dev.conf"
web_port: 29712
Apply complete! Resources: 2 added, 0 changed, 0 destroyed. [txn <id>]
Outputs:
web_file: "prod.conf"
web_port: 28064
The two environments share one specification and differ in the values it computes:
env = dev
port = 29712
workers = 1
env = prod
port = 28064
workers = 3
dev
prod
The addresses are the same in both, port and web. They are paths in the specification. What differs is the state they are recorded in: state/dev and state/prod. This is the whole of what a workspace does for state.
Every command names its environment
pravo keeps no current environment. Each command evaluates the specification again with the tags it receives. A command without -t gets the defaults.
No changes. The specification matches the state. 2 records were not read; run pravo drift to compare them with the infrastructure.
Graph: planned 0 of 2, 2 unchanged and skipped, 2 not read.
No changes. The specification matches the state. 2 records were not read; run pravo drift to compare them with the infrastructure.
Graph: planned 0 of 2, 2 unchanged and skipped, 2 not read.
Each environment finds its own state and reports no changes. Pass the same tags to plan, apply and destroy. Otherwise they evaluate different specifications.
The pravo state commands take -t as well, and read the state of the environment they name:
29712
28064
Break it: an environment that does not exist
The field allows two values. Ask for a third. pravo rejects it when it evaluates the specification, before any provider starts.
Error: evaluating .:
env: 2 errors in empty disjunction:
env: conflicting values "dev" and "qa":
./main.cue:13:7
env: conflicting values "prod" and "qa":
./main.cue:13:15
Read it as path, values, location. The path is env. The message says 2 errors in empty disjunction: pravo tried each alternative, and none accepts "qa". Each conflicting values line compares one alternative with "qa". The position under it is that alternative in main.cue: "dev" first, then "prod".
The error is the same in validate, eval, apply and every other command that evaluates the specification. Check that no provider ran:
no provider started
Change one environment
Give prod four workers. Only the row of prod changes.
package estate
// What differs between environments.
sizes: {dev: 1, prod: 4}
// The row of the environment that is selected.
size: sizes[env]
No changes. The specification matches the state. 2 records were not read; run pravo drift to compare them with the infrastructure.
Graph: planned 0 of 2, 2 unchanged and skipped, 2 not read.
# web (local_file) must be replaced
Plan: 1 to add, 0 to change, 1 to destroy.
Graph: planned 1 of 2, 1 unchanged and skipped, 2 refreshed.
Saved the plan to prod.plan. To apply exactly these actions, run:
pravo apply prod.plan
The dev plan is empty. The prod plan replaces web, because content holds the number of workers. The plan was saved with --out. A saved plan carries the tags it was made with:
Error: -t and -T cannot be combined with a saved plan; the plan carries its own tags
pravo refuses the tag. The plan was made for prod and can only be applied to prod. B7 covers saved plans. Apply it without a tag:
Apply complete! Resources: 1 added, 0 changed, 1 destroyed. [txn <id>]
Outputs:
web_file: "prod.conf"
web_port: 28064
env = prod
port = 28064
workers = 4
Exercise
Add staging
Add a third environment, staging, with two workers. It needs one edit in main.cue and one in sizes.cue. Then apply it.
When you are done, this must print ok:
pravo eval -e size -t env=staging | grep -qx 2 && pravo plan -t env=qa 2>&1 | grep -q 'empty disjunction' && test -f staging.conf && echo ok
Hint 1
Add "staging" to the disjunction of env and a row to sizes. Keep "dev" as the default.
Hint 2
pravo validate accepts a missing row. pravo eval -e size -t env=staging reports it as undefined field: staging. A6 covers the disjunction.
Solution
package estate
import (
"pravo.dev/pravo"
rnd "pravo.dev/providers/hashicorp/random"
loc "pravo.dev/providers/hashicorp/local"
)
$random: rnd.#Provider
$local: loc.#Provider
// The environment: dev unless -t env=... says otherwise.
env: *"dev" | "staging" | "prod" @tag(env)
// One state directory per environment.
$settings: pravo.#Settings & {state: path: "state/\(env)"}
package estate
// What differs between environments.
sizes: {dev: 1, staging: 2, prod: 4}
// The row of the environment that is selected.
size: sizes[env]
Apply complete! Resources: 2 added, 0 changed, 0 destroyed. [txn <id>]
Outputs:
web_file: "staging.conf"
web_port: 27815
web_file: "staging.conf"
web_port: 27815
dev
prod
staging
Clean up
Destroy each environment with its own tag. If you applied staging in the exercise, destroy it the same way with -t env=staging.
Destroy complete! Resources: 0 added, 0 changed, 2 destroyed. [txn <id>]
Destroy complete! Resources: 0 added, 0 changed, 2 destroyed. [txn <id>]
Recap
- An input is a field with a default, a constraint and
@tag(name).-t name=valuefills it. - The state path is computed from the environment, so each environment has its own state and the same addresses.
- A value outside the constraint is refused at evaluation, and a saved plan carries the tags it was made with.
Check yourself
What does pravo remember between commands about the current environment?
Nothing. Every command evaluates the specification again with the tags it is given.
Why can a plan made with -t env=prod not be applied with -t env=dev?
The saved plan carries its own tags. It can only be applied to the environment it was made for.
Reference: Tags and variables, Settings and the how-to Environments.
B5 generates many resources from one rule.