The layout in this series is a target. Getting an existing repository there is done one stack at a time: move the code, copy the state object, and prove with a plan that nothing changed. Every step is mechanical and the proof is one line of output. That is why the last section hands the procedure to an agent.
Why one stack at a time
The legacy layout is app/<stack>/<env>/, each directory with its own backend
block and a state key based on its own path. That key is what ties a directory
to the resources it manages. Converting the whole tree at once would have
every stack planning against a key that does not exist yet, with no rollback
smaller than one large revert. Per stack, a failed migration is one pull
request to close.
Not every stack is equally safe to move first:
| Class | Signals | Action |
|---|---|---|
| Go | single AWS context, no remote-state coupling, rarely changes | migrate now |
| Review | several provider aliases, git-ref module sources, provider major differs from the generated constraint | migrate with care |
| Hold | reads or is read via terraform_remote_state, owns shared networking or DNS, inline secrets in *.tf | migrate later, as a group |
A terraform_remote_state producer and its consumers move together, or the
producer first. Otherwise the consumer keeps reading a snapshot that is no
longer updated.
Move the code, copy the state
Scaffolding comes first, so that the new backend key exists on paper before anything is copied into it:
terramate create stacks/aws/<account-folder>/<region>/<stack-name>git mv app/<stack-name>/<env>/main.tf \ stacks/aws/<account-folder>/<region>/<stack-name>/main.tfThe moved main.tf loses its provider and backend blocks, which the
generators now write. Every relative module source gains the ../ segments
the deeper path needs. What stays is the module calls and any stack-local
resources.
The state copy is a plain S3 object copy between the old key and the generated one, guarded on both sides:
# 1. the legacy object existsaws s3api head-object --profile <profile> \ --bucket <legacy-bucket> --key <legacy-key>
# 2. the new key is still empty (if not, back it up first)aws s3api head-object --profile <profile> \ --bucket <new-bucket> --key <new-key>
# 3. copy, never moveaws s3api copy-object --profile <profile> --bucket <new-bucket> \ --copy-source <legacy-bucket>/<legacy-key> --key <new-key>Copying rather than moving keeps the migration reversible. Revert the pull
request and the legacy directory goes back to planning against its untouched
key, with the copy left behind as an orphan. Comparing ETag and
ContentLength on both objects is a cheap check that the copy is complete.
The plan gate
The state is copied byte for byte and the code is moved rather than rewritten. So the plan on the pull request refreshes every resource in the copied state, compares it with the moved configuration, and should find nothing to do:
No changes. Your infrastructure matches the configuration.Anything else means stop. In practice it is one of two things. Either the
generated provider constraint resolves to a different major version than the
legacy lock file. That is fixed in the declaration, never by upgrading to
clear a diff. Or a global resolved from the new path, the profile or the
region, is not the one the legacy stack used. Then the plan fails to
authenticate, or wants to recreate everything somewhere else. Rarer causes are
a renamed provider alias, a module source off by one ../, and path-sensitive
functions such as file() or path.root now evaluated from a different
directory. Each produces a plan that looks like an infrastructure change and
is not.
The legacy directory is deleted in the same pull request, and it has to be the whole directory. A pipeline that discovers projects by directory will still find an empty one and fail to initialise it.
Handing it to an agent
A runbook whose steps never vary is a script written as prose. This one has the shape agents are good at: every input is in the repository, every step is mechanical, and success is a diff shape rather than a judgement call. So it became a skill, a Markdown file an OpenCode agent loads on demand, with the steps above and the rules it may not negotiate:
- Hold by default. A stack migrates only if the classification clears it; anything coupled or high-blast-radius is reported and left to a person.
- Copy state, never move it. No
terraform state mv, no deleting the legacy object. - Never apply locally.
terramate generate,terraform fmtand read-onlyaws s3apicalls; nothing else. - Match provider versions exactly. Never upgrade a major to clear a diff.
- An empty plan is the gate. Anything else stops the run.
- Confirm before opening a pull request, and before every apply.
Each rule closes off a shortcut that would look reasonable to an agent halfway through a migration.
Plans and applies run through Atlantis, which plans when a pull request opens and applies when someone comments on it. That fixes two things about the procedure. The state copy lands before the pull request opens, and the agent prepares but never applies. It opens the pull request, reads the plan against the gate, and asks. Approval comes from a different identity than the one that wrote the branch. Applying is one comment per stack directory, in order, because a single comment naming several directories does not work once a pull request carries more than one project:
gh pr comment "$PR" \ --body "atlantis apply -d stacks/aws/<account>/<region>/<stack>"gh pr checks "$PR" --watchRunning several migrations at once is where the agent pays off. One git worktree per stack, outside the repository so neither Terramate nor Atlantis scans it, each with its own branch, pull request and agent:
git worktree add ../migrate-<stack> -b migrate/<stack>-to-terramateThe paths each migration touches do not overlap, and the shared imports are
never edited. So the runs collide on exactly one file. Every migration edits
CODEOWNERS to drop the legacy directory it deleted, and the first one to
merge leaves the others with a conflict there. Rebase and re-plan. It is
mechanical, and worth knowing before three agents report the same failure at
once.
Limitations
An empty plan proves that the copied state, the moved code and what is deployed agree. It does not prove the legacy directory has stopped being applied. Freeze it once its object is copied, and delete it in the migration pull request.
The state copy needs credentials for the destination account, so that step stays on a workstation. Everything else is a pull request.
The classification is a guide, not a rule. It reads signals, and a stack can look like a clean candidate and still be the one nobody wants replanned on a Friday. The gate catches mistakes. The classification only orders the work.
An agent is fast at the mechanical part and no better than the runbook at the judgement part. That is why holding is the default and the confirmations are not optional.
What’s next?
A migrated repository is correct and harder to read: the same stack name now lives in several accounts and regions, five directory levels down. Building an interactive TUI for Terramate stacks is the tool I built to get the overview back.
Comments