Portfolio · Notes · Dotfiles

Search everything

Search case studies, engineering notes, and Dotfiles documentation.

    all notes

    Generating Terraform for teams from one JSON file

    Self-service for developers: a team describes its Slack channel, GitHub teams and on-call rotation in one JSON file, and the Terraform is generated.

    Adding a team is a directory, a JSON file and a pull request. The file says what the team has, a Slack channel, GitHub teams, an on-call rotation, and a generator writes the Terraform for it; nobody copies it from the team next door.

    Why a team is infrastructure

    A team exists in more places than the org chart: a GitHub team with repository permissions, a Slack channel, an on-call schedule with an escalation policy, and the links between them. Each lives in a different system with its own admin console, and a team assembled by clicking through them is as consistent as the person who did the clicking.

    Declaring teams as code gives them what infrastructure already has: one declaration produces the same footprint in every system, every change is a reviewed pull request, a channel renamed by hand shows up as drift in the next plan, and removing a team is the reverse of creating one. What a team has to say about itself is small, which GitHub teams, which channel, which hours the rotation covers. Everything else is policy, written once for every team, and Terramate’s code generation supplies it.

    The team descriptor

    In the layout from the first post, a stack is a directory under stacks/ marked by a stack.tm.hcl file, and it inherits globals from the directories above it. Teams get a subtree of their own, stacks/teams/<team>, beside the account-and-region hierarchy rather than inside it, because a team does not belong to one AWS account:

    stacks/teams/
    ├── teams.tm.hcl parses team.json, holds the generators
    ├── team.schema.json what a descriptor may contain
    ├── team-platform/ one stack per team
    │ ├── stack.tm.hcl the marker, from terramate create
    │ └── team.json the descriptor, hand written
    ├── team-payments/
    ├── team-search/
    ├── team-mobile/
    └── team-data/

    The directory name is the team’s identity, and the descriptor is the only file a person writes:

    stacks/teams/team-platform/team.json
    {
    "team_name_readable": "Platform",
    "github": {
    "teams": [
    { "type": "default" },
    { "type": "frontend", "description": "Frontend engineers" }
    ]
    },
    "slack": {
    "channel": "team-platform",
    "topic": "Platform team, public"
    },
    "oncall": {
    "schedules": [
    {
    "timezone": "Europe/Warsaw",
    "daily_start_time": "09:00:00",
    "daily_end_time": "17:00:00"
    }
    ]
    }
    }

    Nothing here names a Terraform module, a provider or a resource. The keys are platforms and the values are the two or three facts each platform needs; every default lives in the generator.

    A JSON Schema, checked in pre-commit and in continuous integration, rejects a malformed descriptor. The three platform definitions are the long part of the file and are left out here; what matters is additionalProperties closed at every level, so a misspelled key is an error rather than a silently ignored one:

    stacks/teams/team.schema.json
    {
    "$schema": "https://json-schema.org/draft-07/schema#",
    "type": "object",
    "additionalProperties": false,
    "required": ["team_name_readable"],
    "properties": {
    "team_name_readable": { "type": "string" },
    "github": { "$ref": "#/definitions/github" },
    "slack": { "$ref": "#/definitions/slack" },
    "oncall": { "$ref": "#/definitions/oncall" }
    }
    }

    Misspell type inside github.teams and the check fails:

    check-jsonschema --schemafile stacks/teams/team.schema.json \
    stacks/teams/*/team.json
    # Schema validation errors were encountered.
    # stacks/teams/team-platform/team.json::$.github.teams[1]:
    # 'type' is a required property

    Parsing it with Terramate

    One teams.tm.hcl at the root of stacks/teams applies to every team directory below it. Two globals do the parsing:

    stacks/teams/teams.tm.hcl
    globals {
    team_name = terramate.stack.path.basename
    team_config = tm_jsondecode(tm_file("team.json"))
    }

    Globals are evaluated per stack, so this one file yields a different team_config in every team directory, read from that directory’s own descriptor. The directory name doubles as the team’s name, which is why the descriptor never repeats it.

    One generated file per platform

    Each platform gets its own generate_hcl block in the same file, guarded by whether the descriptor mentions it at all. As in the provider generator, functions prefixed tm_ are Terraform’s, run at generation time:

    stacks/teams/teams.tm.hcl
    generate_hcl "100_terramate_generated_github_teams.tf" {
    condition = tm_can(global.team_config.github)
    lets {
    github_teams = {
    for team in tm_try(global.team_config.github.teams, []) :
    "github_team_${team.type}" => {
    # path from the stack back up to the repository root
    source = "${terramate.stack.path.to_root}/modules/github-team"
    team_name = global.team_name
    team_type = team.type
    team_description = tm_try(team.description, "Managed with Terraform")
    }
    }
    }
    content {
    tm_dynamic "module" {
    for_each = let.github_teams
    iterator = team
    labels = [team.key]
    attributes = team.value
    }
    }
    }

    A block whose condition is false produces no file, so a team’s generated files mirror its descriptor’s top-level keys:

    team.json the descriptor teams.tm.hcl generate_hcl × 3 if github if slack if oncall 100_terramate_generated_github_teams.tf one module per github team 101_terramate_generated_slack.tf one slack module 102_terramate_generated_oncall.tf one module per schedule
    One descriptor in, one Terraform file out per platform it names; a platform the descriptor leaves out produces no file.

    team-platform declares three platforms and team-payments none yet, so one gets three files and the other only the two every stack gets from the shared imports:

    terramate generate
    # - /stacks/teams/team-payments
    # [+] 000_terramate_generated_terraform.tf
    # [+] 001_terramate_generated_providers.tf
    #
    # - /stacks/teams/team-platform
    # [+] 000_terramate_generated_terraform.tf
    # [+] 001_terramate_generated_providers.tf
    # [+] 100_terramate_generated_github_teams.tf
    # [+] 101_terramate_generated_slack.tf
    # [+] 102_terramate_generated_oncall.tf
    #
    # ... team-search, team-mobile, team-data ...

    Files 000 and 001 come from the shared imports in part one and part two. The 100 file is the team’s own: one module call per entry in the descriptor’s github.teams array, keyed by type:

    stacks/teams/team-platform/100_terramate_generated_github_teams.tf
    // TERRAMATE: GENERATED AUTOMATICALLY DO NOT EDIT
    module "github_team_default" {
    source = "../../../modules/github-team"
    team_description = "Managed with Terraform"
    team_name = "team-platform"
    team_type = "default"
    }
    module "github_team_frontend" {
    source = "../../../modules/github-team"
    team_description = "Frontend engineers"
    team_name = "team-platform"
    team_type = "frontend"
    }

    Onboarding and teardown

    Adding a team is four steps, and only one of them is a decision:

    Terminal window
    mkdir stacks/teams/team-search
    # write stacks/teams/team-search/team.json
    terramate create stacks/teams/team-search
    git add stacks/teams/team-search && git commit

    terramate create writes the stack.tm.hcl marker with its identifier and runs generation, so the pull request contains the descriptor plus every file derived from it. A reviewer reads the JSON, and the HCL beside it either matches the policy or the generator has a bug.

    In the production repository this runs in, a few dozen descriptors, about a thousand lines of JSON in all, and a generator under three hundred lines stand in for several thousand lines of Terraform that nobody writes or reviews.

    Teardown runs the same machinery backwards. The real schema wraps the object shown earlier in a oneOf whose other branch accepts an empty object and nothing else:

    stacks/teams/team.schema.json
    {
    "$schema": "https://json-schema.org/draft-07/schema#",
    "oneOf": [
    {
    "type": "object",
    "properties": {},
    "additionalProperties": false
    },
    { "$ref": "#/definitions/team" }
    ]
    }

    So a team is torn down by emptying its descriptor, which removes every conditional file:

    echo '{}' > stacks/teams/team-platform/team.json
    terramate generate
    # - /stacks/teams/team-platform
    # [-] 100_terramate_generated_github_teams.tf
    # [-] 101_terramate_generated_slack.tf
    # [-] 102_terramate_generated_oncall.tf

    The module calls are gone from the configuration, so the next plan is a destroy of exactly those resources, reviewable as a diff before anyone runs it.

    Limitations

    Deleting a key is a destroy. That is correct, and it is also one keystroke from removing a channel a team is using; the schema and the review are the only guardrails.

    The descriptor can only say what the generator already understands. A new platform, or a field the modules do not take yet, is a change to teams.tm.hcl and to a module, which is platform-team work; self-service ends at the schema.

    Accepting {} costs error quality. A oneOf makes the validator report the branch it judged closest, and for a descriptor with one typo that is the empty-object branch, so the message blames every top-level key instead of the misspelled one. Moving the empty case out of the schema and into the pre-commit hook fixes that.

    JSON has no comments, so the reason behind an unusual value lives in the commit message. The schema’s description fields are the only documentation next to the file.

    A generator change rewrites every team’s files, so a policy change lands as one commit touching every team directory.

    What’s next?

    The next post in the series is about doing this at scale: taking an existing repository of a few hundred flat Terraform directories into the layout the first three posts describe, one stack at a time, with a plan gate strict enough that an agent can do the moving. Migrating Terraform stacks to Terramate with an agent is that procedure, and what it took to hand it over.

    Comments