A Terramate repository describes its
infrastructure as a tree of small, independently planned units, and
Atlantis is a natural partner for it: every
pull request gets a plan, and every apply happens in the open. But Atlantis
does not read Terramate’s tree. It reads its own list of projects, kept in
atlantis.yaml, and the moment that list exists it becomes a second copy of
the tree that someone has to keep in step with the first.
This post introduces terramate-atlantis-config, a small Go program that derives that list from the stacks on every pull request, and walks through the features you are likely to reach for first: generating the list, naming projects, carrying per-stack settings as globals, narrowing the list, and deciding where the file is generated. It assumes some familiarity with Terramate stacks and globals; if they are new to you, the earlier post on structuring a Terramate monorepo is a better starting point.
A few terms
Both tools have a name for the same idea, so it helps to pin the vocabulary down before going further.
A stack is Terramate’s unit: one directory with a stack.tm.hcl file, one
Terraform root module, one state. A project is Atlantis’ unit: a directory
(and workspace) that Atlantis plans and applies as one thing. Throughout this
post a stack becomes at most one project, so the two words describe the
same directory seen from either side.
Atlantis reads two kinds of configuration. The repo-level config is the
atlantis.yaml at the root of the repository, which lists the projects and
their per-project settings. The server-side repo config, usually called
repos.yaml, lives on the Atlantis server and decides what a repository is
allowed to set in its own atlantis.yaml. A pre-workflow hook is a command
the server-side config tells Atlantis to run in its checkout of a pull request
before it reads the repo-level config.
Finally, a project autoplans when a pull request modifies a file matching
one of its when_modified patterns, which are relative to the project’s
directory.
Most of what follows is a translation between the two vocabularies, and the table below is a map of it. The last two rows belong to the next post, but they are listed here so the shape of the whole is visible:
| Terramate has | Atlantis needs | Covered in |
|---|---|---|
| a stack directory | a project with a dir | this post |
| the stack’s path | a unique project name | this post |
atlantis_* globals | per-project settings | this post |
after and before | depends_on and order groups | the next post |
| files a stack reads | when_modified patterns | the next post |
Why Atlantis misses a new stack
Atlantis can run without any atlantis.yaml at all. In that mode it
discovers projects on its own, by looking at which directories a pull
request touched. Most Terramate repositories outgrow this quickly, because
discovery gives every project the same settings: there is no way to say that
production stacks run a stricter workflow, or need more approvals before
apply, than development ones. Those settings live in atlantis.yaml, and so
an atlantis.yaml appears.
The catch is in how Atlantis treats discovery once that file exists. Its
repo-level config documentation
puts it in one line: in the default auto mode, “projects will be discovered
only if the repo has no projects configured.” List one project and
discovery switches off for all of them.
The failure that follows is a quiet one. A pull request adds a new stack. It is not in the list, so Atlantis plans nothing and says nothing about it; the pull request looks clean, it merges, and the stack never reaches the cloud. Nothing failed, which is exactly why nobody notices.
There are two obvious fixes, and both are reasonable. The first is
discipline: whoever adds a stack also adds its project, and reviewers check.
That works, right up until the first time it doesn’t, and the cost of that
one time is an unapplied stack. The second is to set autodiscover.mode to
enabled, which makes Atlantis discover unlisted directories even when
projects are configured. That closes the gap, but per-project settings
belong to entries in the list, and a discovered project has no entry. A new
production stack would be planned and applied under whatever the server’s
defaults happen to be.
Set side by side, the options differ less in whether a new stack gets planned than in where its settings come from:
| Approach | New stack planned? | Its settings come from |
|---|---|---|
| List projects by hand | only once listed | its list entry, kept by reviewers |
autodiscover.mode: enabled | yes | the server’s defaults |
| Generate the list | yes | the stack’s own globals |
What the first two have in common is that the list is still maintained beside the tree rather than derived from it. That suggests a rule of thumb: when one file describes another, generate it. This is the same reasoning behind terragrunt-atlantis-config, which does this job for Terragrunt and which the Atlantis pre-workflow hook documentation uses as its own example. terramate-atlantis-config applies the idea to Terramate stacks, their globals and their ordering.
The demo repository
It’s easier to discuss this with something concrete, so the rest of this
post uses the demo repository, which is wired to a real Atlantis server. Its
stacks hold only terraform_data resources, so they plan without any cloud
credentials. Here is its layout, abridged to the files that matter:
.terramate-atlantis.yamlatlantis/repos.yamlstacks/├── aws/│ ├── aws.tm.hcl│ ├── acme-development/│ │ ├── account.tm.hcl│ │ └── eu-west-1/│ │ ├── app/│ │ └── network/│ └── acme-production/│ ├── account.tm.hcl│ └── eu-west-1/│ ├── app/│ ├── manual/│ ├── network/│ └── skipped/└── imports/ └── context.tm.hclThere are two accounts, each with an account.tm.hcl holding the globals
for every stack below it. Production has two extra stacks, manual and
skipped, whose names hint at what they will demonstrate. Each scenario
below has a matching
pull request
showing Atlantis’ actual response.
Generating the project list
The tool has one main command. Run from the repository root, it finds every stack and writes one project per stack:
$ terramate-atlantis-config generateGenerated atlantis.yaml with 5 projectsIt reads stack definitions and globals through the Terramate SDK, so the
terramate CLI is not needed to run it, and it changes nothing except the
file it writes. The output path comes from .terramate-atlantis.yaml, the
tool’s own config file, which in the demo holds just two settings:
output: atlantis.yamlapply_requirements: [mergeable]The same settings can be passed as flags, such as
--apply-requirements mergeable, and a flag overrides the file. The file is
the better home for anything that should hold for every run, because the
same settings then apply on a laptop, in CI and on the Atlantis server
without anyone repeating them.
The second line is a default for every project: a pull request has to be mergeable before Atlantis will apply it. Here is the first of the generated projects:
# Generated by terramate-atlantis-config. DO NOT EDIT.# Re-generate with: terramate-atlantis-config generateversion: 3automerge: falseparallel_plan: trueparallel_apply: trueprojects: - name: acme-development-eu-west-1-app dir: stacks/aws/acme-development/eu-west-1/app apply_requirements: - mergeable autoplan: enabled: true when_modified: - '*.tf' - '*.tf.json' - .terraform.lock.hcl# ... four more projects ...Here we have a project per stack, with its dir set to the stack’s path and
autoplan watching the stack’s Terraform files. The repository has six stacks
but only five projects; the missing one is skipped, for reasons we’ll come
to shortly. Before that, the project name deserves a closer look, because it
is the one part of this file that people end up typing.
How projects are named
An Atlantis project’s name is optional, but it must be unique across the
repository, and names are what you pass to atlantis plan -p when you want
one project rather than all of them. The tool derives the name from the
stack’s path, and it has four strategies for doing so. Running each against
the demo gives these names for the development app stack:
| Strategy | Name of the development app stack |
|---|---|
auto-strip (default) | acme-development-eu-west-1-app |
stack-name | aws-acme-development-eu-west-1-app |
last-n, depth 2 | acme-development-eu-west-1-app |
full | stacks-aws-acme-development-eu-west-1-app |
auto-strip removes the path segments that every stack shares, here
stacks/aws/, since they carry no information. full keeps them.
stack-name uses the stack’s name attribute and up to three parent
directories.
The last-n row is the interesting one. With a depth of two, both app
stacks would be called eu-west-1-app, so the tool adds parent segments to
each until they differ, and the result is the same as auto-strip. The
manual stack exists in only one account, so nothing collides and it
keeps the short name, eu-west-1-manual. A strategy is a starting point;
uniqueness always wins.
For an introduction the default is the right choice, and the
project naming documentation
covers the rest. There is one property of auto-strip that matters later,
though: because the shared prefix depends on every stack, adding a stack in
a new place can change it. We’ll return to that under the limits below.
Per-stack settings as globals
Generating the list solves the missing-project problem, but on its own it
would just reproduce what discovery already did. The more useful part is
that per-project settings come from the stacks too, as Terramate
globals
whose names start with atlantis_. The tool knows seven of them:
| Global | Type | Effect on the project |
|---|---|---|
atlantis_skip | bool | no project at all |
atlantis_workflow | string | sets workflow |
atlantis_apply_requirements | list | sets apply_requirements |
atlantis_autoplan | bool | turns autoplan on or off |
atlantis_terraform_version | string | sets terraform_version |
atlantis_extra_deps | list | adds when_modified entries |
atlantis_when_modified | list | replaces when_modified |
Globals are inherited: a global set in a directory applies to every stack below it. That makes the account directory the natural place for settings that differ by environment. The production account sets two:
globals { environment = "production" account_name = "acme-production"
# Every production stack uses the server's "production" workflow # and needs more checks before apply. atlantis_workflow = "production" atlantis_apply_requirements = ["mergeable", "undiverged"]}Every production project picks both up, while the development projects keep the repository default:
- name: acme-production-eu-west-1-network dir: stacks/aws/acme-production/eu-west-1/network workflow: production apply_requirements: - mergeable - undiverged # ... autoplan, as before ...undiverged additionally blocks an apply if main has changed since the
last plan; it needs the merge checkout strategy, which the demo’s Compose
file sets. The production workflow itself is not defined in
atlantis.yaml. It lives in the server-side config, the demo’s
atlantis/repos.yaml, and adds one step before the plan:
workflows: production: plan: steps: - init - run: terraform validate -no-color - planThe two remaining stacks each set a single global of their own. skipped
sets atlantis_skip = true, which leaves it out of the generated file
entirely, and manual sets atlantis_autoplan = false, which keeps it in
the file but stops Atlantis from planning it until someone asks. Putting
the account’s globals and the stacks’ own together, the six stacks come
out like this:
| Stack | workflow | apply_requirements |
|---|---|---|
development app, network | server default | mergeable |
production app, network | production | mergeable, undiverged |
production manual, autoplan off | production | mergeable, undiverged |
production skipped | no project | no project |
Nothing in that table was written per stack except the last two rows, and those were written in the stacks they describe. A new production stack lands in the second row without anyone deciding that it should.
What the server must allow
There is one trap here worth knowing about in advance. Atlantis only accepts
an atlantis.yaml whose keys the server-side config allows, and if a single
project breaks that rule it rejects the whole file, not just that
project. A file generated with the tool’s defaults uses none of the
restricted keys, but the settings above do:
| The generated file has | Because of | The server must allow |
|---|---|---|
a project workflow | atlantis_workflow | workflow in allowed_overrides |
project apply_requirements | the global, or a default in the config file | apply_requirements in allowed_overrides |
a top-level workflows block | generated workflows | allow_custom_workflows, set to true |
The demo uses the first two, so its server-side config allows both:
repos: - id: /.*/ pre_workflow_hooks: - run: terramate-atlantis-config generate --output atlantis.yaml description: terramate-atlantis-config allowed_overrides: - workflow - apply_requirementsThe troubleshooting guide
lists the messages Atlantis prints when a key is missing, such as
repo config not allowed to set 'workflow' key. The practical consequence
is an ordering rule for rollouts: allow the key on the server first, and
only then add the global that produces it.
Misspelled and mistyped globals
A misspelled global fails softly. The tool reads only the globals it knows
and warns about any other atlantis_* name. Renaming atlantis_workflow
to atlantis_workflows in the production account gives:
Warning: unknown global "atlantis_workflows" (stacks: stacks/aws/acme-production/eu-west-1/app, stacks/aws/acme-production/eu-west-1/manual, stacks/aws/acme-production/eu-west-1/network and 1 more) is ignored; known atlantis globals: atlantis_apply_requirements, atlantis_autoplan, atlantis_extra_deps, atlantis_skip, atlantis_terraform_version, atlantis_when_modified, atlantis_workflowThe run still succeeds, and the production projects quietly lose their
workflow, so the warning deserves a place in CI logs where someone will see
it. A global of the wrong type is treated differently. Setting
atlantis_autoplan = "no" in the manual stack fails the run rather than
guessing what was meant:
Error: discovering stacks: extracting atlantis globals for stack "/stacks/aws/acme-production/eu-west-1/manual": atlantis_autoplan must be boolThe difference is deliberate, as far as I can tell: an unknown name might belong to some other tool’s convention, while a known name with the wrong type can only be a mistake.
Generating only part of the list
So far every stack has become a project. Most of the time that is what you want, but there are occasions to narrow the list: a repository shared by two Atlantis servers, say, or a directory of stacks that some other system applies. The tool has filters by path and by Terramate tag, and each has an include and an exclude form. Run against the demo, the path filters give:
| Flags | Projects generated |
|---|---|
| none | all five |
--filter-path stacks/aws/acme-production | the three production projects |
--exclude-path '**/manual' | four: all but manual |
--filter-path '**/network' | the two network projects |
A pattern selects a directory and everything nested under it, * matches
within one path segment and ** across any number of them. The tag forms,
--filter and --exclude-tags, take the same syntax as terramate run --tags and match the tags attribute of a stack; the demo’s stacks have no
tags, so they are not shown here.
Two properties of filters are easy to miss. A filter never renames a
project, because names are always computed against every stack that isn’t
skipped, so a project called acme-production-eu-west-1-app keeps that name
whether or not a given run includes it. And atlantis_skip is applied after
the filters, so a skipped stack stays skipped whatever the flags say.
There is also a third kind of filter, --changed, which keeps only the
stacks Terramate considers changed against a Git ref. It sounds like the
obvious thing to run on a pull request, and the next post explains why it is
usually not.
Where the file is generated
The repos.yaml above also answers a question the tool leaves open: where
atlantis.yaml gets generated. There are two reasonable answers, and the
choice is a tradeoff rather than a best practice.
The first is to generate the file locally and commit it. A
pre-commit hook regenerates it on every commit,
and CI fails the build when the committed copy is stale. --check makes
that decision with an exit code, and --diff shows why. Adding a dns
stack to the development account without regenerating gives:
$ terramate-atlantis-config generate --check --diff--- atlantis.yaml+++ atlantis.yaml@@ -7,6 +7,16 @@ projects: - name: acme-development-eu-west-1-app dir: stacks/aws/acme-development/eu-west-1/app+ apply_requirements:+ - mergeable+ autoplan:+ enabled: true+ when_modified:+ - '*.tf'+ - '*.tf.json'+ - .terraform.lock.hcl+ - name: acme-development-eu-west-1-dns+ dir: stacks/aws/acme-development/eu-west-1/dns apply_requirements: - mergeable autoplan:atlantis.yaml is out of date, run the same command without --check and --diff to update itThe diff is a little awkward to read, because projects are sorted by
directory and the new one slots in after app, but the exit code of 1 is
what CI acts on. A companion command, validate, checks an existing file
for the problems that would make Atlantis reject it, such as duplicate
project names or a depends_on entry that names no project:
$ terramate-atlantis-config validate atlantis.yamlatlantis.yaml is validCommitting keeps every change to the project list visible in review, which is valuable when the list is short and changes rarely. Its cost is that every new stack, rename, or settings change produces a diff in a generated file that reviewers mostly skim.
The second is to let Atlantis generate it, with the pre-workflow hook shown
earlier. Atlantis clones the pull request, runs the hook, and only then
reads atlantis.yaml, so the file exists only in Atlantis’ checkout and
never in Git:
This needs the binary in the Atlantis image (the demo’s Dockerfile installs a checksummed release), and it means nobody reviews the generated file at all. It also adds a failure mode that committing does not have.
Laid out side by side, the two setups trade review for noise:
| What changes | Commit the file | Generate it in Atlantis |
|---|---|---|
| Runs the tool | pre-commit hook, CI --check | a pre-workflow hook |
| The file in review | every change, as a diff | never; only its inputs |
| The Atlantis image needs | nothing extra | the tool’s binary |
| A stale file is caught by | --check in CI | can’t be stale |
| Tool warnings appear in | the committer’s terminal, CI | the hook’s job page in Atlantis |
The last row is worth a word. Atlantis does not post a hook’s output as a
comment. It shows it on a job page on the Atlantis server, linked from the
hook’s atlantis/pre_workflow_hook status on the pull request, so a warning
such as the misspelled global above is seen only by someone who clicks
through. With the file generated on the server, it’s worth also
running the tool in CI, if only to put its warnings in front of the person
who caused them.
The demo takes the second path, because the project list is fully determined by files that are reviewed. If you would want to read the file, commit it; if it would only ever be rubber-stamped, generate it.
What the pull requests show
Each of the demo’s three pull requests isolates one behavior:
| Pull request | Changes | Atlantis plans |
|---|---|---|
| #1 | adds a development stack, new-stack | the new stack |
| #2 | network in both accounts | both, production with validate |
| #3 | skipped, manual and development app | app only |
In the first, nobody edited atlantis.yaml. The hook generated it, found
the stack, and Atlantis planned it under its derived name:
Ran Plan for project: `acme-development-eu-west-1-new-stack` dir: `stacks/aws/acme-development/eu-west-1/new-stack` workspace: `default`This is the failure from the start of the post, closed: a new stack is planned because it exists, not because someone remembered to list it.
In the second, both network projects are planned, but only the production
plan begins with Success! The configuration is valid., the output of the
validate step its workflow adds. The two plans differ because the
directories above them differ, and nothing about either project was written
by hand.
In the third, skipped does not appear anywhere, since it is not a
project. manual is a project, but it waits until someone comments:
atlantis plan -p acme-production-eu-west-1-manualThat comment is also why the naming discussion earlier matters: a project name is part of how people operate the repository, not only an identifier in a file.
Limits of this approach
Generating the list removes one kind of drift, but it does not make Atlantis understand Terramate, and a few limits are worth stating plainly.
Project names are derived, so they can move. auto-strip removes whatever
prefix all stacks share, and adding a stack outside stacks/aws/ would
change that prefix and rename every project, along with every
atlantis plan -p command that names one. Filters never rename projects,
but changes to the tree can. Pinning the prefix with
--project-name-prefix (project_name_prefix in the config file) avoids
this, and the next post shows it happening.
The default when_modified patterns look only at a stack’s own directory.
That is fine here, because the demo commits Terramate’s generated code, so a
change to a global that the generated code reads also changes the .tf
files in every stack below it. Stop committing that code and a change to a
global plans nothing at all, which is the subject of the next post.
Lastly, the two demos in this series have six and five stacks. How generation time grows with hundreds of stacks is not measured here, and I’d encourage trying the tool against a copy of a real repository before relying on it.
Where to from here?
We’ve seen how a generated atlantis.yaml keeps the project list in step
with the stacks, how project names are derived, how atlantis_* globals
carry per-stack settings down the tree, and what changes depending on where
the file is generated. The idea underneath is small: the stacks already
describe what Atlantis needs to know, so Atlantis should learn it from them.
The next
post
takes the obvious next step of removing Terramate’s generated code from Git
too, and deals with what Atlantis loses when it does. In the meantime, the
demo repository is built to be forked: make up starts an Atlantis server
against your copy, and make e2e opens the pull requests above and checks
Atlantis’ answers.
Comments