§ How-to · Operate

Run pravo in a pipeline

Validate without credentials, plan under a policy, apply a saved plan, find drift and test offline, with the exit code of every step.

Time
15 min
Level
intermediate
Needs
pravo and jq on the runner. B7 teaches saved plans and B10 teaches policies.
Done when
A plan job saves a plan under a policy, an apply job applies that file, and each step ends with the exit code in the table.

View as Markdown

On this page

A pipeline needs four answers: does the specification evaluate, what changes, what exactly was applied and did anything change outside pravo. Each is a command with an exit code.

Work in an empty directory. It stands for the repository. The root is app/ and the policy is policy/plan.cue, outside the root. Every command uses -C app, which runs pravo as if it started in app/.

app/main.cue
package ci

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

// Pinned, so that a pipeline cannot pick up a new major version unnoticed.
$random: rnd.#Provider & {$version: "~> 3.6"}
$local: loc.#Provider & {$version: "~> 2.5"}

server: $random.#Resource.random_pet & {length: 2}
port: $random.#Resource.random_integer & {min: 8000, max: 8999}

note: $local.#Resource.local_file & {
	filename: "note.txt"
	content:  "\(server.id)\n"
}
policy/plan.cue
// A managed resource is neither deleted nor replaced.
changes: [...{
	addr:   string
	mode:   string
	action: string
	if mode == "managed" && (action == "delete" || action == "replace") {
		error("\(addr): \(action) is not allowed")
	}
}]

1. Check without credentials

pravo init installs the providers and generates the packages that validate, plan and apply need. pravo validate evaluates the specification against them. It starts no provider and needs no credentials.

terminal
$test -z "$(pravo -C app fmt)" && echo "formatted"
$pravo -C app init > /dev/null
$pravo -C app validate
formatted
Success! 3 resources, 0 data sources, 0 ephemeral, 0 function calls, 0 outputs across 2 providers.

pravo fmt prints the name of each file it rewrites, so a clean tree prints nothing and test -z passes. A mistake in the specification gives exit code 6 from validate.

app/bad.cuerejected
package ci

server: length: "two"
terminalexit 6
$pravo -C app validate
Error: evaluating ./app:
server.length: conflicting values "two" and 2 (mismatched types string and int):
    ./app/bad.cue:3:17
    ./app/main.cue:12:49

plan and apply report the same error with exit code 6. A pipeline tells a mistake in the code from a failed plan by the code.

terminal
$rm app/bad.cue

2. Ask whether a change is pending

Apply once. Then plan -detailed-exitcode exits 0 when the plan is empty and 2 when it has changes.

terminal
$pravo -C app apply -auto-approve | grep '^Apply complete'
$pravo -C app plan -detailed-exitcode > /dev/null
$echo "exit=$?"
Apply complete! Resources: 3 added, 0 changed, 0 destroyed. [txn <id>]
exit=0

Add a resource.

app/extra.cue
package ci

extra: $random.#Resource.random_pet & {length: 2}
terminal
$pravo -C app plan -detailed-exitcode > /dev/null
$echo "exit=$?"
exit=2

A job reads the code from a command that is not in a pipe, because a pipe hides it.

Exit code 0 means that the plan found no change among the resources it looked at: the ones a change reaches and the ones they read. It does not mean that every resource matches its object. A plan does not read the other records, unless it is asked to with -refresh=expired or -refresh=all or a resource sets a refresh age of its own that has run out, so it is not a drift detector. Step 5 finds drift.

3. Read a plan as data

Every command accepts --json. Standard output carries the data, and progress goes to standard error. plan prints one document.

terminal
$pravo -C app plan --json 2>/dev/null | jq -c '.summary, [.changes[] | select(.action != "no-op") | {addr, action}]'
{"add":1,"change":0,"destroy":0,"read":0,"blast_radius":1}
[{"addr":"extra","action":"create"}]

The addr of a change is the address of the resource. A pipeline can post the summary on a pull request or compare a number with a budget. apply prints one JSON event per line and ends with a summary. A plain apply --json that has changes to make needs -auto-approve, because there is no prompt.

4. Plan, then apply the file

The plan job saves a plan under the policy. The apply job applies that file and names the policy itself.

terminal
$pravo -C app plan -out change.plan --actor ci --reason "build 42" --policy ../policy/plan.cue | grep -E '^(Plan|Policy|Saved)'
Plan: 1 to add, 0 to change, 0 to destroy.
Policy ../policy/plan.cue (sha256:<digest>) is satisfied.
Saved the plan to change.plan. To apply exactly these actions, run:

--actor and --reason are recorded on the plan and on the transaction. They are labels that the caller supplies and pravo does not verify. pravo show prints the plan with its label, and --json gives the same as data.

terminal
$pravo -C app show change.plan | head -n 1
Saved plan created <time> by "pravo -C app plan --out change.plan --actor ci --reason build 42 --policy ../policy/plan.cue" (actor ci)

A saved plan is checked against the specification, the provider versions and the state it touches before anything runs. Change the specification after the plan was made.

app/extra.cue
package ci

extra: $random.#Resource.random_pet & {length: 3}
terminalexit 3
$pravo -C app apply change.plan --policy ../policy/plan.cue
Error: saved plan is stale; run "pravo plan" again:
  - the configuration changed since the plan was made

Exit code 3 means that the plan is stale. Plan again and apply the new file.

terminal
$pravo -C app plan -out change.plan --actor ci --reason "build 43" --policy ../policy/plan.cue > /dev/null
$pravo -C app apply change.plan --policy ../policy/plan.cue
Policy ../policy/plan.cue (sha256:<digest>) is satisfied.
extra: Creating...
extra: Creation complete after 0s [id=<id>]

Apply complete! Resources: 1 added, 0 changed, 0 destroyed. [txn <id>]

Under a policy an apply is of a saved plan. A plain pravo apply --policy ... is refused with exit code 5. The second plan that it makes is not the one that was judged. Write and roll out plan policies covers the policy.

5. Find drift on a schedule

pravo drift reads every resource from its provider, ignores refresh ages and exits 2 when something changed outside pravo. Delete the file behind pravo's back.

terminalexit 2
$rm app/note.txt
$pravo -C app drift
Drift. 1 of 4 resources changed outside pravo:
  note: create
    - refresh found the real state differs from the state (drift)

With -webhook <url> the report is also posted as JSON, so a scheduler can hand drift to whoever triages it.

pravo drift reads every record, and an estate of many records takes long to read. Two other jobs read fewer.

  • pravo drift --due --limit N reads the records that are past their refresh age, the oldest first, at most N of them, and plans nothing. Each run costs what it reads. A job that runs it every few minutes reads the whole estate in turn, and a record that was never read goes first. It exits 2 when it finds drift.
  • pravo plan -refresh=expired reads every record that is past its refresh age and plans the specification as well. Use it where a job must see both.

A read that finds drift leaves a mark on the record. The next plan, saved plan and apply act on it, whichever job found it, and it stays until an apply writes the resource.

6. Test without providers or credentials

A job that only asks whether a change to the specification still produces the expected plan needs no provider and no credentials. Record the responses once. Replay them in every run.

Record a plan from an empty state in a scratch copy of the root.

terminal
$mkdir record
$cp app/*.cue record
$pravo -C record init > /dev/null
$PRAVO_RECORD=recordings pravo -C record plan | grep -E '^Plan'
$ls record/recordings
Plan: 4 to add, 0 to change, 0 to destroy.
local
random

The recordings directory holds the provider's responses, one directory per provider. A response carries the attributes of the objects the provider returned, the sensitive ones included, and the result of an ephemeral resource. A recording is as sensitive as state, so handle it as you handle state. pravo creates its directories with mode 0700 and its files with mode 0600. It does not write the values of a request, so the credentials in a provider configuration and write-only attributes are not in it.

Version control keeps no file modes. Commit the recording with the specification only when no resource in it returns a secret. Otherwise keep it where the pipeline keeps state, and fetch it before the job. A fresh clone of the repository with an empty provider store replays the recordings.

terminal
$mkdir clone empty-home
$cp record/*.cue clone
$cp -r record/recordings clone
$PRAVO_HOME=$PWD/empty-home PRAVO_REPLAY=recordings pravo -C clone init
$PRAVO_HOME=$PWD/empty-home PRAVO_REPLAY=recordings pravo -C clone plan -detailed-exitcode | grep -E '^Plan'
Configuration does not load yet; generating schemas for the declared providers first.
Created cue.mod/module.cue
Providers:
  local = hashicorp/local v2.9.1
  random = hashicorp/random v3.9.1
Up to date cue.mod/gen/pravo.dev/providers/hashicorp/local (2 resource types, 3 data sources, 1 ephemeral, 1 functions)
Up to date cue.mod/gen/pravo.dev/providers/hashicorp/random (10 resource types, 0 data sources, 2 ephemeral, 0 functions)

pravo has been initialised. Run "pravo plan" to see what it would do.
Plan: 4 to add, 0 to change, 0 to destroy.

The clone has an empty provider store, and replay answers from the recordings. No provider binary was downloaded:

terminal
$test -z "$(ls -A empty-home)" && echo "the provider store is empty"
the provider store is empty

A change that alters what pravo asks the provider is a missing recording.

terminalexit 1
$sed 's/length: 3/length: 5/' clone/extra.cue > clone/extra.tmp
$mv clone/extra.tmp clone/extra.cue
$PRAVO_HOME=$PWD/empty-home PRAVO_REPLAY=recordings pravo -C clone plan
Error: extra: planning: PlanResourceChange random_pet: no recording for this request (replaying from recordings/random)

When the change is intended, record again and store the new recordings where the old ones are. When it is not, you caught it before a real provider saw it. Replay shows that pravo and the specification still agree on the requests. It does not show that a real provider behaves the same; pravo drift on a schedule covers that.

7. Refuse a replacement

Change server so that the provider has to replace it.

app/main.cue
package ci

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

// Pinned, so that a pipeline cannot pick up a new major version unnoticed.
$random: rnd.#Provider & {$version: "~> 3.6"}
$local: loc.#Provider & {$version: "~> 2.5"}

server: $random.#Resource.random_pet & {length: 3}
port: $random.#Resource.random_integer & {min: 8000, max: 8999}

note: $local.#Resource.local_file & {
	filename: "note.txt"
	content:  "\(server.id)\n"
}
terminal
$pravo -C app plan -out replace.plan --policy ../policy/plan.cue > plan.txt
$echo "exit=$?"
$grep -E '^(Plan|Error|changes)' plan.txt
$test -f app/replace.plan || echo "no plan file"
exit=5
Plan: 2 to add, 0 to change, 1 to destroy.
Error: the plan violates the policy ../policy/plan.cue (sha256:<digest>):
changes.1: server: replace is not allowed:
no plan file

The plan is printed first and judged afterwards. Exit code 5 means that a policy refused the run, and no plan file was written. With --json, standard output carries the plan document and then the error object, so a consumer takes the object whose kind is error.

Exit codes

Code Meaning Where it appears
0 Success. For plan -detailed-exitcode, no changes. Every command.
1 An error: a missing recording, apply --json without -auto-approve, a provider or state error. Step 6.
2 Changes pending (plan -detailed-exitcode) or drift found (drift). Steps 2 and 5.
3 A saved plan is stale, or a node that an apply writes or reads moved after the plan was made. A job plans again and retries. Step 4.
4 Rejected: a node is locked or reserved by another actor. A job retries later. Let an agent plan and apply.
5 Refused by a policy, or by a module that does not match its lock. Steps 4 and 7.
6 A command that loads the specification found that it does not load or evaluate. Step 1.

With --json, an error also prints {"code":N,"kind":"error","message":"..."} on standard output. The lookup page is Exit codes.

One pipeline

The commands of this page, in the jobs that run them:

pull request   pravo init; test -z "$(pravo fmt)"; pravo validate
               PRAVO_REPLAY=recordings pravo plan --json > plan.json
plan job       pravo plan -out change.plan --actor ci --reason "$REV" --policy "$POLICY"
apply job      pravo apply change.plan --policy "$POLICY"
schedule       pravo drift --due --limit 500 --actor drift-cron

validate, plan and apply need the generated packages under cue.mod/gen. A job runs pravo init first, or the repository commits cue.mod/gen. The $POLICY file is checked out from a protected branch, and the apply job is defined where the authors of the roots cannot change it unreviewed.

What next