The Lifecycle
Working out what a change will do, and doing it, are usually the same command. Somebody types
apply, reads what scrolls past, and answers a prompt while the process holding the plan waits for
them. That works until the person who should read the plan is not the person at the keyboard, and
until an approval has to live somewhere other than a terminal session.
Octo splits the work into four commands, each its own process:
npm run octo:generate # 1. intent -> terragrunt module folders
npm run octo:validate # 2. folders -> a report against intent (optional)
LOCK_ID=$(npm run --silent octo:lock) # steps 3 and 4 write shared state
npm run octo:apply -- --lockId $LOCK_ID # 3. folders -> real infrastructure
npm run octo:commit -- --lockId $LOCK_ID # 4. outputs -> octo state, and the lock is released
Nothing reaches the cloud before step 3, the first two need no permission to change anything, and a pipeline can put a human between any two of them.
Steps 3 and 4 are the two that write shared state, so both require the app lock, which keeps two runs from writing at once. A commit that succeeds releases it, so a run that completes leaves nothing behind. The app lock covers taking it, and finishing a run that died holding one.
The dotted line is the part that makes the cycle a cycle. Octo does not read your cloud account to work out what exists; it reads what it recorded the last time it ran. The shop from Shop Stack is the example throughout: a region, a receipts bucket, and a checkout server that can read and write it.
Generate
octo generate -o .octo-tf
Generate turns your definition file into Terraform: one Terragrunt folder per module, holding
main.tf, variables.tf, outputs.tf, and terragrunt.hcl. It needs no credentials and touches
nothing in the cloud, and the same definition file produces the same folders whatever ran before it.
It records your intent in .octo/models.json as it goes. There is no resource state yet, because
nothing has been created.
Three things about how it writes, all of which matter the first time you delete something:
- Folders are overwritten in place, not wiped.
terraform.tfstate,.terragrunt-cache, and any folder Octo does not recognize survive a regenerate. - A module you removed from your intent has its folder emptied, keeping the Terraform block and the providers but no resources. An empty configuration is what makes the next apply destroy the infrastructure that folder holds.
- Generate never deletes a folder. Deleting it would orphan whatever is in its state file. The commit after the destroy deletes it, once its state holds nothing.
If a module's inputs fail validation, generate throws before writing anything. A graph that does not hold together never becomes a folder.
Validate
octo validate --terragruntDir .octo-tf
Validate runs terraform plan on every folder itself, so there is no separate plan step, and then
correlates each plan against Octo's own diff. It answers two questions, and keeps them apart:
- What will change, and why. Including Terraform changes Octo did not ask for directly but can attribute to something it did, such as a referenced resource whose identity moved.
- What changed that your intent does not explain. That is drift, and it means somebody edited infrastructure outside Octo.
Validate is a report rather than a verdict. Drift and unexplained changes are findings to act on, not exit codes. The only thing that fails the command is being unable to read a plan at all.
The report carries a confidence level, and it changes how much the drift section is worth. Reading a Validate Report covers it before you rely on one.
Apply
octo apply --terragruntDir .octo-tf --lockId $LOCK_ID
The only step that changes live infrastructure. It verifies the lock before it plans anything, so a run without one fails having done nothing.
It runs terragrunt run --all -- apply over the directory, and the logging listener prints the
output as it goes, because an ECS apply can run for tens of minutes and its progress is the only sign
it is alive. Without LoggingEventListener in listeners, the apply prints nothing unless it fails
or refuses a destroy.
Terragrunt is given -auto-approve, so there is no prompt to answer. Octo puts its own gate in
front instead: apply plans first, and refuses to run if anything would be destroyed. A destroy that
takes data with it, the bucket's objects here, says so on its line.
This apply would DESTROY:
[region-module] aws_internet_gateway.igw-eu-west-1 (delete)
[receipts-module] aws_s3_bucket.bucket-shop-receipts (delete) - the data it holds is lost with it
Error: Refusing to apply. Run "octo validate" to see why each of these is being destroyed, then re-run with --auto-approve to proceed!
--auto-approve skips that check, and it is required for every destroy, including one you meant.
Only removing an external resource,
such as an old image tag, goes through without it, since Octo deletes that at commit. If
a folder cannot be planned at all, apply also refuses, because it cannot say whether that folder
would destroy something and will not guess. Either way the lock stays held: re-run the apply with
the same lock ID, or release it with octo unlock --lockId $LOCK_ID if you are not going ahead.
CLI lists every flag it takes.
Commit
octo commit --terragruntDir .octo-tf --lockId $LOCK_ID
Commit reads what the apply produced, the ARNs and IDs and endpoints the provider handed back, and records them as state. It is also the step that ends a run, so a commit that succeeds releases the app lock.
It is all or nothing. If an expected output is missing, which is what a partial apply looks like,
commit throws before touching state rather than writing half a record, and keeps the lock, so
running it again once you have fixed the apply is safe. After an apply that failed and is not going
to succeed as it stands, --partial records the half that was built and reports what it skipped.
Recovering a Failed Run covers every way a run can stop
short of its commit.
Without a commit, the next run has no record that any of this exists and proposes creating all of it again.
What each step touches
| Cloud | Model state | Resource state | Generated folders | App lock | |
|---|---|---|---|---|---|
generate | writes | writes | |||
validate | reads a plan | reads | |||
apply | writes | reads | requires | ||
commit | reads outputs | writes | writes | reads | requires, releases |
Generate is the only step that runs without credentials of any kind, and the only one you can run freely while somebody else holds the lock.
In a pipeline
The split is what makes gating possible. A pull-request job runs the two read-only steps and publishes the report. An apply job, after somebody has read it, applies.
# On a pull request. No credentials that can change anything, and no lock.
- run: npm run octo:generate
- run: npm run octo:validate
# Once a human has approved the report above.
- run: npm run octo:generate
- run: |
LOCK_ID=$(npm run --silent octo:lock)
echo "LOCK_ID=$LOCK_ID" >> $GITHUB_ENV
- run: npm run octo:apply -- --lockId $LOCK_ID
- run: npm run octo:commit -- --lockId $LOCK_ID
Regenerate on the apply job rather than carrying the folders over from the pull-request job. Generate is deterministic and takes milliseconds, and doing it again guarantees the folders match the state as it is when the apply starts, not as it was when the report was written. GitHub Actions Pipeline has the complete workflow, including where the state directory has to live for any of this to be safe.
The commands you never run
octo run-action and octo get-response are not part of the cycle. Terraform calls them itself,
mid-run, for the kind of resource it does not manage. Nothing is
required of you: validate and apply set up what those callbacks need.