§ C2 · Track C · A team on one state

Store a plan

Put a plan in the state, so that the other person can list it, read it, see that it touches the same resource as theirs, and apply it by its id. What makes a stored plan stale, and when it goes away.

Time
15 min
Level
intermediate
Needs
C1
You will learn
Where does a plan go when the person who made it is not the person who applies it? How does pravo say that two plans want the same resource? When is a stored plan refused, and when is it removed?
Done when
Dan lists and reads the plan Ada stored, Ada applies it by its id, Dan's plan for the same resource is stale with exit code 3, and a plan that expired is collected.

View as Markdown

On this page

B7 saved a plan to a file and applied the file, so that what runs is what was read. The file belongs to the machine that made it. In OpenTofu the same is true of terraform plan -out=tfplan: the plan is an artifact that a pipeline passes from one job to the next, and nobody else knows that it exists. Two people can plan the same resource on the same afternoon and find out at apply time.

a plan file is yours alone
terraform plan -out=tfplan      # on your machine
terraform apply tfplan          # on your machine, or wherever you carry the file

pravo plan --store writes the plan into the state, next to the records. Anyone who can read the state can list it, read it and apply it by its id, from any checkout, and pravo tells each person which other plans want the same resources. A stored plan is checked as a plan file is: against the specification it was made from, the providers and the revision of every record it touches.

Store a plan

Work in an empty directory, with the two checkouts of C1: Ada's root names the in-memory bucket, and Dan's checkout is a copy of it. Nothing is applied yet.

ada/main.cue
package team

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

$settings: pravo.#Settings & {
	state: pravo.#S3State & {
		backend:    "s3"
		bucket:     "northwind-state"
		prefix:     "prod"
		region:     "eu-west-1"
		endpoint:   "http://127.0.0.1:9000"
		path_style: true
	}
}

$random: rnd.#Provider

port: $random.#Resource.random_integer & {min: 20000, max: 29999, seed: "web"}
terminal
$pravo -C ada init > /dev/null
$cp -r ada dan

Ada plans with --store. The plan is printed as always, and the last lines say where it went:

terminal
$pravo -C ada --actor ada --reason "Reserve a port for the web server" plan --store | tail -n 4
Graph: planned 1

Stored the plan as plan-<id>; unless it is applied it is removed after 7 days. To apply exactly these actions, run:
  pravo apply plan-<id>

The id is plan- and the first eight hexadecimal digits of the plan's digest, so it differs on every run. The lesson keeps it in a file, and $(cat ada.id) stands for the id that pravo printed. Nothing was applied.

terminal
$pravo -C ada plan list --json | jq -r '.plans[] | select(.author == "ada") | .id' > ada.id

Read it from the other checkout

Dan lists the plans of the state and reads Ada's:

terminal
$pravo -C dan plan list
$pravo -C dan plan show $(cat ada.id) | head -n 7
plan-<id>  pending  ada  just now  +1 ~0 -0  expires in 7 days
Stored plan plan-<id> by ada, just now (pending)
  reason:  Reserve a port for the web server
  command: pravo -C ada --actor ada --reason Reserve a port for the web server plan --store
  expires in 7 days
  apply:   pravo apply plan-<id>

pravo will perform the following actions:

The listing has the id, the status, the author, the age, the counts of resources to add, change and destroy, and a note. pending means that the plan can be applied. plan show prints the plan itself after a header: the reason, the command that made it, when it expires and the command that applies it. The Plans section of the viewer shows the same list, with the resources that each plan touches drawn on the graph: See what your team has planned.

Two plans for one resource

Dan wants the range one narrower and changes his checkout.

dan/main.cue
package team

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

$settings: pravo.#Settings & {
	state: pravo.#S3State & {
		backend:    "s3"
		bucket:     "northwind-state"
		prefix:     "prod"
		region:     "eu-west-1"
		endpoint:   "http://127.0.0.1:9000"
		path_style: true
	}
}

$random: rnd.#Provider

port: $random.#Resource.random_integer & {min: 20000, max: 29998, seed: "web"}

A stored plan is not a lock. Ask what pravo knows at this point, and what it can tell Dan.

Predict Dan stores a plan for port, which Ada's stored plan also creates. Does pravo refuse it?

No. The plan is stored, and pravo warns Dan that Ada's plan changes the same resource. The warning is information: it stops nothing and changes nothing in Ada's plan.

terminal
$pravo -C dan --actor dan --reason "Keep 29999 free" plan --store | tail -n 5
$pravo -C dan plan list --json | jq -r '.plans[] | select(.author == "dan") | .id' > dan.id
Graph: planned 1

Warning: plan-<id> by ada (just now) also changes 1 of these resources: port
Stored the plan as plan-<id>; unless it is applied it is removed after 7 days. To apply exactly these actions, run:
  pravo apply plan-<id>

plan list --touching ADDRESS lists the open plans that change one resource, and a plan that is not stored warns in the same way:

terminal
$pravo -C dan plan list --touching port
plan-<id>  pending  dan  just now  +1 ~0 -0  expires in 7 days
plan-<id>  pending  ada  just now  +1 ~0 -0  expires in 7 days

The Plans section of the viewer, pravo -C dan serve and then p, lists both, newest first, with the note each author wrote as the title and a mark on each row that says another plan changes one of the same resources. View lays the plan of a row over the graph.

The Plans section of the viewer. The head says 2 stored plans can still be applied, of 2 in the state, and the switch reads Pending 2 and All 2. Two pending plans are listed: Keep 29999 free, by dan, 3 min ago, +1, whose row reads Viewing and is tinted, and Reserve a port for the web server, by ada, 12 min ago, +1, with a View button. Each row carries the badge of its author, the chip pending, a ring for the time that is left, a mark of two rings that says another plan changes one of the same resources, and the id of the plan in small type. The inspector shows the project team. The Plans section of the viewer. The head says 2 stored plans can still be applied, of 2 in the state, and the switch reads Pending 2 and All 2. Two pending plans are listed: Keep 29999 free, by dan, 3 min ago, +1, whose row reads Viewing and is tinted, and Reserve a port for the web server, by ada, 12 min ago, +1, with a View button. Each row carries the badge of its author, the chip pending, a ring for the time that is left, a mark of two rings that says another plan changes one of the same resources, and the id of the plan in small type. The inspector shows the project team.

fig. 1 Both plans in the Plans section. The mark of two rings on each row says that the other plan changes the same resource.

Dan's checkout differs from the files Ada planned from, so Ada's plan cannot be applied from it. plan show says so, and names the file:

terminal
$pravo -C dan plan show $(cat ada.id) | grep '^Warning'
Warning: this plan can no longer be applied: the configuration changed since the plan was made (main.cue changed)

That is the check of B7, across checkouts. A stored plan is applied from a checkout that has the files it was made from, which in a repository means the same commit.

Apply by id

Ada applies her plan by its id, from her checkout. The checks are those of a plan file: the configuration, the providers, the lineage of the state and the revision of every record the plan touches.

terminal
$pravo -C ada apply $(cat ada.id)
port: Creating...
port: Creation complete after 0s [id=<id>]

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

Break it: the plan that came second

Dan's plan assumed that port does not exist. The record has revision 1 now, and his plan expected revision 0. The listing shows it, and the apply refuses it.

terminal
$pravo -C dan plan list
$pravo -C dan plan list --all
No stored plan can be applied. "pravo plan list --all" lists the others.
plan-<id>  stale    dan  just now  +1 ~0 -0  port changed in state (revision 1, plan expected 0)
plan-<id>  applied  ada  just now  +1 ~0 -0  applied just now
terminalexit 3
$pravo -C dan apply $(cat dan.id)
Error: saved plan is stale; run "pravo plan" again:
  - port changed in state (revision 1, plan expected 0)

Exit code 3 is a stale plan, as in Lab 2. Nothing was changed, and Dan plans again against the new state. A plan with several changes is judged change by change: when the state moved under some and not under the others, it is partly-stale, and the listing prints the --only set that applies the rest.

What goes away, and when

A stored plan is removed when it expires, seven days after it was made unless --ttl says otherwise, when it has been stale for an hour, or a day after it was applied. pravo plan gc is the collection. The first collection that finds a plan stale marks it and removes nothing, so that a plan that is stale for a moment, while someone else's apply runs, is not lost:

terminal
$pravo -C dan plan gc
1 plan can no longer be applied; a collection that runs 1 hour later removes it:
  plan-<id>  dan        stale, port changed in state (revision 1, plan expected 0)

pravo plan --store runs a small collection of its own, so a team that stores plans needs no schedule for it. A plan that an apply is running from is never removed.

Exercise

Exercise

Let a plan expire

Store a plan as Dan with a time to live of two seconds, wait three, and list the plans with and without --all. Then collect it. The expired plan should be listed only with --all before the collection, and not at all after it.

Hint 1

--ttl takes 2s, 90m, 36h or 7d. sleep 3 waits.

Hint 2

plan gc --dry-run says what a collection would remove and changes nothing. The plan without it removes the expired plan on the first run, since expiry needs no hour of grace.

Solution
terminal
$pravo -C dan --actor dan plan --store --ttl 2s | tail -n 2
$sleep 3
$pravo -C dan plan list
$pravo -C dan plan list --all | head -n 1
$pravo -C dan plan gc
Stored the plan as plan-<id>; unless it is applied it is removed after a moment. To apply exactly these actions, run:
  pravo apply plan-<id>
No stored plan can be applied. "pravo plan list --all" lists the others.
plan-<id>  expired  dan  just now  +1 ~0 -1  expired a moment ago
Removed 1 stored plan:
  plan-<id>  dan        expired a moment ago

apply refuses an expired plan with exit code 3 before a collection has removed it, so a time to live is a promise that the plan is not applied after it.

Clean up

terminal
$pravo -C ada destroy --auto-approve | tail -n 1
Destroy complete! Resources: 0 added, 0 changed, 1 destroyed. [txn <txn>]

Recap

  • pravo plan --store writes the plan into the state, and anyone who can read the state lists it, reads it and applies it by its id, from a checkout that has the files it was made from.
  • Two plans that change one resource are not refused. Each is warned about the other, and the one that is applied second is stale: exit code 3, nothing changed.
  • A stored plan expires after seven days, or after --ttl, and pravo plan gc removes what nobody will apply, with an hour of grace for a plan that is stale.

Check yourself

Dan stored a plan while Ada's plan for the same resource was pending. What did pravo do?

It stored Dan's plan and warned him that Ada's plan changes port as well. A stored plan is not a lock. The plan that is applied second finds the record at a revision it did not expect, and is stale.

Why could Dan not apply Ada's plan from his checkout?

His main.cue differs from the one the plan was made from. A plan is applied from the files it was read from, which in a repository means the same commit.

The how-to page Store a plan and apply it later adds plan pull, the apply of a part of a plan and the pipeline that stores in one job and applies in another. Stored plans and approvals explains how a listing judges a plan and how an apply claims it. The next lesson, Seal the state, takes the values out of the bucket, stored plans included.