§ How-to · Specify

Run one specification in several environments

Select dev, staging or prod with a tag, keep the differences in one table and give each environment its own state.

Time
8 min
Level
intermediate
Needs
pravo on your PATH and an empty directory. B4 teaches the idea; this page is the recipe.
Done when
pravo apply -t env=prod and the same command for dev write separate state directories, and an environment without a row in the table is caught before an apply.

View as Markdown

On this page

One specification serves every environment. A tag selects the environment, one table holds what differs, and the state path follows the tag.

Work in an empty directory. The commands are for a POSIX shell.

1. Declare the environment

env.cue holds the environment, the table and the state path.

env.cue
package estate

import "pravo.dev/pravo"

// The environment: dev unless -t env=... says otherwise.
env: *"dev" | "staging" | "prod" @tag(env)

// What differs between environments.
_envs: {
	dev: {workers: 1, retention: 7}
	staging: {workers: 2, retention: 30}
	prod: {workers: 3, retention: 90}
}

// The row of the selected environment.
cfg: _envs[env]

// One state directory per environment.
$settings: pravo.#Settings & {state: path: "state/\(env)"}
  • The disjunction lists the allowed values, * marks the default and @tag(env) lets -t env=... fill the field.
  • _envs is hidden, so pravo does not send it to a provider. cfg is the row that env selects.
  • state.path is computed from env, so each environment has its own state.

2. Write the resources once

main.cue reads cfg and never names an environment.

main.cue
package estate

import (
	rnd "pravo.dev/providers/hashicorp/random"
	loc "pravo.dev/providers/hashicorp/local"
)

$random: rnd.#Provider
$local:  loc.#Provider

server: $random.#Resource.random_pet & {length: cfg.workers}

conf: $local.#Resource.local_file & {
	filename: "\(env).conf"
	content:  "env \(env)\nserver \(server.id)\nretention \(cfg.retention)\n"
}
terminal
$pravo init > /dev/null
$pravo validate
Success! 2 resources, 0 data sources, 0 ephemeral, 0 function calls, 0 outputs across 2 providers.

3. Select an environment

pravo eval -e prints one expression. Ask for the row with and without a tag.

terminal
$pravo eval -e cfg
$pravo eval -e cfg -t env=prod
workers:   1
retention: 7
workers:   3
retention: 90

Without a tag, env takes its default. pravo keeps no current environment. Every command evaluates the specification again with the tags it receives, so pass the same -t to plan, apply and destroy.

4. Apply each environment

terminal
$pravo apply -auto-approve | grep '^Apply complete'
$pravo apply -auto-approve -t env=prod | grep '^Apply complete'
Apply complete! Resources: 2 added, 0 changed, 0 destroyed. [txn <id>]
Apply complete! Resources: 2 added, 0 changed, 0 destroyed. [txn <id>]

The addresses are the same in both runs, server and conf. The state is not.

terminal
$ls state
$grep -h retention dev.conf prod.conf
dev
prod
retention 7
retention 90

The pravo state commands take -t, and read the state of the environment they name. Without -t they read the default environment.

terminal
$pravo state list -t env=prod
conf
server

5. Add an environment

A new environment is a new value in the disjunction and a new row in the table. Start with the value only.

env.cueunintended
package estate

import "pravo.dev/pravo"

// The environment: dev unless -t env=... says otherwise.
env: *"dev" | "staging" | "prod" | "qa" @tag(env)

// What differs between environments.
_envs: {
	dev: {workers: 1, retention: 7}
	staging: {workers: 2, retention: 30}
	prod: {workers: 3, retention: 90}
}

// The row of the selected environment.
cfg: _envs[env]

// One state directory per environment.
$settings: pravo.#Settings & {state: path: "state/\(env)"}

qa is allowed and has no row. pravo validate does not notice, because a value that is not yet concrete is not an error for it.

terminalexit 1
$pravo validate -t env=qa
$pravo plan -t env=qa
Success! 2 resources, 0 data sources, 0 ephemeral, 0 function calls, 0 outputs across 2 providers.

Error: server.length: cfg: undefined field: qa

pravo eval -c requires every value to be concrete. Run it on cfg for every environment, in a loop, before you merge.

terminalexit 1
$for e in dev staging prod qa; do pravo eval -c -e cfg -t env=$e; done
workers:   1
retention: 7
workers:   2
retention: 30
workers:   3
retention: 90

Error: -e "cfg": undefined field: qa:
    ./env.cue:16:12

The message names the row that is missing. Add it.

env.cue
package estate

import "pravo.dev/pravo"

// The environment: dev unless -t env=... says otherwise.
env: *"dev" | "staging" | "prod" | "qa" @tag(env)

// What differs between environments.
_envs: {
	dev: {workers: 1, retention: 7}
	staging: {workers: 2, retention: 30}
	qa: {workers: 1, retention: 14}
	prod: {workers: 3, retention: 90}
}

// The row of the selected environment.
cfg: _envs[env]

// One state directory per environment.
$settings: pravo.#Settings & {state: path: "state/\(env)"}
terminal
$for e in dev staging prod qa; do pravo eval -c -e cfg -t env=$e > /dev/null; done
$pravo plan -t env=qa | grep '^Plan'
Plan: 2 to add, 0 to change, 0 to destroy.

6. Refuse an unknown environment

A value outside the disjunction is rejected when pravo evaluates the specification, before any provider starts.

terminalexit 6
$pravo plan -t env=sandbox
Error: evaluating .:
env: 4 errors in empty disjunction:
env: conflicting values "dev" and "sandbox":
    ./env.cue:6:7
env: conflicting values "prod" and "sandbox":
    ./env.cue:6:27
env: conflicting values "qa" and "sandbox":
    ./env.cue:6:36
env: conflicting values "staging" and "sandbox":
    ./env.cue:6:15

To make the tag mandatory, remove *"dev" | from the field. A command without -t, including each pravo state command, then stops with $settings.state.path is not concrete. pravo providers lock, pravo providers schema and pravo mod verify do not apply -t, so in such a root they stop with the same error.

Clean up

terminal
$pravo destroy -auto-approve | grep '^Destroy complete'
$pravo destroy -auto-approve -t env=prod | grep '^Destroy complete'
Destroy complete! Resources: 0 added, 0 changed, 2 destroyed. [txn <id>]
Destroy complete! Resources: 0 added, 0 changed, 2 destroyed. [txn <id>]

What next