Portfolio · Notes · Dotfiles

Search everything

Search case studies, engineering notes, and Dotfiles documentation.

    all notes

    Generating Atlantis config from Terramate stacks

    Atlantis learns which Terramate stacks exist, and how to plan each, from the stacks themselves.

    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 hasAtlantis needsCovered in
    a stack directorya project with a dirthis post
    the stack’s patha unique project namethis post
    atlantis_* globalsper-project settingsthis post
    after and beforedepends_on and order groupsthe next post
    files a stack readswhen_modified patternsthe 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:

    ApproachNew stack planned?Its settings come from
    List projects by handonly once listedits list entry, kept by reviewers
    autodiscover.mode: enabledyesthe server’s defaults
    Generate the listyesthe 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.yaml
    atlantis/repos.yaml
    stacks/
    ├── 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.hcl

    There 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 generate
    Generated atlantis.yaml with 5 projects

    It 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:

    .terramate-atlantis.yaml
    output: atlantis.yaml
    apply_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 generate
    version: 3
    automerge: false
    parallel_plan: true
    parallel_apply: true
    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
    # ... 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:

    StrategyName of the development app stack
    auto-strip (default)acme-development-eu-west-1-app
    stack-nameaws-acme-development-eu-west-1-app
    last-n, depth 2acme-development-eu-west-1-app
    fullstacks-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:

    GlobalTypeEffect on the project
    atlantis_skipboolno project at all
    atlantis_workflowstringsets workflow
    atlantis_apply_requirementslistsets apply_requirements
    atlantis_autoplanboolturns autoplan on or off
    atlantis_terraform_versionstringsets terraform_version
    atlantis_extra_depslistadds when_modified entries
    atlantis_when_modifiedlistreplaces 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:

    stacks/aws/acme-production/account.tm.hcl
    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:

    atlantis/repos.yaml (excerpt)
    workflows:
    production:
    plan:
    steps:
    - init
    - run: terraform validate -no-color
    - plan

    The 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:

    Stackworkflowapply_requirements
    development app, networkserver defaultmergeable
    production app, networkproductionmergeable, undiverged
    production manual, autoplan offproductionmergeable, undiverged
    production skippedno projectno 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 hasBecause ofThe server must allow
    a project workflowatlantis_workflowworkflow in allowed_overrides
    project apply_requirementsthe global, or a default in the config fileapply_requirements in allowed_overrides
    a top-level workflows blockgenerated workflowsallow_custom_workflows, set to true

    The demo uses the first two, so its server-side config allows both:

    atlantis/repos.yaml (excerpt)
    repos:
    - id: /.*/
    pre_workflow_hooks:
    - run: terramate-atlantis-config generate --output atlantis.yaml
    description: terramate-atlantis-config
    allowed_overrides:
    - workflow
    - apply_requirements

    The 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_workflow

    The 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 bool

    The 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:

    FlagsProjects generated
    noneall five
    --filter-path stacks/aws/acme-productionthe 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 it

    The 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.yaml
    atlantis.yaml is valid

    Committing 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:

    The hook writes the file into Atlantis' clone of the pull request before Atlantis reads any repo-level config, which is what lets the file be generated rather than committed.

    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 changesCommit the fileGenerate it in Atlantis
    Runs the toolpre-commit hook, CI --checka pre-workflow hook
    The file in reviewevery change, as a diffnever; only its inputs
    The Atlantis image needsnothing extrathe tool’s binary
    A stale file is caught by--check in CIcan’t be stale
    Tool warnings appear inthe committer’s terminal, CIthe 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 requestChangesAtlantis plans
    #1adds a development stack, new-stackthe new stack
    #2network in both accountsboth, production with validate
    #3skipped, manual and development appapp 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-manual

    That 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