Post

Ansible Linux Sandbox - Part 4

Ansible Linux Sandbox - Part 4

The code was working well enough that I started thinking about what happened when it wasn’t: a PR with broken Terraform formatting or an unqualified module name would merge without complaint, and I would find out whenever I happened to run things locally.

I had watched platform engineers run CI pipelines at clients I support without really understanding what building one involved. Coming into this with no prior CI/CD experience, I worked through it with Claude as a pair and ended up with two GitHub Actions workflows, neither of which touches live infrastructure. One validates and format-checks the Terraform files, the other lints the Ansible files, and both trigger on pull requests targeting main scoped to the paths they actually care about.

OIDC Authentication

The Terraform workflow needs to assume an IAM role, and the obvious approach of storing an access key and secret in GitHub secrets is also the wrong one: long-lived credentials in a secret store are a category of risk that has a cleaner answer here.

GitHub Actions has a built-in identity provider that AWS IAM can be configured to trust, so when the workflow runs it requests a short-lived token from GitHub’s OIDC endpoint, presents it to AWS STS, and assumes a role without any static credentials in the picture.

The AWS side requires registering the GitHub Actions OIDC provider in IAM and creating a role with a trust policy scoped to the specific repository. I had a working draft of the structure and worked through it with Claude to understand what each condition was actually enforcing.

iam-role-github-actions-ansible-linux-sandbox.png

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
resource "aws_iam_openid_connect_provider" "github_actions" {
  url             = "https://token.actions.githubusercontent.com"
  client_id_list  = ["sts.amazonaws.com"]
  thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]
}

resource "aws_iam_role" "github_actions" {
  name = "iam-role-github-actions-ansible-linux-sandbox"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Principal = {
          Federated = aws_iam_openid_connect_provider.github_actions.arn
        }
        Action = "sts:AssumeRoleWithWebIdentity"
        Condition = {
          StringEquals = {
            "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
          }
          StringLike = {
            "token.actions.githubusercontent.com:sub" = "repo:orionilloc/ansible-linux-sandbox:*"
          }
        }
      }
    ]
  })
}

The sub claim in a GitHub Actions OIDC token encodes the repository and context, something like repo:orionilloc/ansible-linux-sandbox:ref:refs/heads/main for a push to main or repo:orionilloc/ansible-linux-sandbox:pull_request for a PR. Using StringLike with a wildcard allows both, and the aud condition pins the token to STS specifically, so only workflows from this repository can assume the role and only when GitHub’s OIDC endpoint issued the token.

The role only has read permissions and doesn’t touch running infrastructure. Because the workflow skips the S3 backend entirely, it doesn’t need state access either.

The Terraform Workflow

terraform-ci.yml triggers on pull requests targeting main, scoped to changes under v3-dynamic-inventory/**.tf, so a PR that only touches playbooks won’t run it.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
# .github/workflows/terraform-ci.yml

name: Terraform CI

on:
  pull_request:
    branches:
      - main
    paths:
      - 'v3-dynamic-inventory/**.tf'

permissions:
  id-token: write
  contents: read

jobs:
  terraform:
    name: Validate & Format Check
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Configure AWS credentials via OIDC
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: $
          aws-region: us-east-1

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3

      - name: Terraform Init
        working-directory: v3-dynamic-inventory
        run: terraform init -backend=false

      - name: Terraform Format Check
        working-directory: v3-dynamic-inventory
        run: terraform fmt --check

      - name: Terraform Validate
        working-directory: v3-dynamic-inventory
        run: terraform validate

The permissions block is one of those things that isn’t prominently documented: without id-token: write, the workflow can’t request an OIDC token from GitHub at all, and the credential step fails with an error that doesn’t make the missing permission obvious.

terraform init -backend=false is what keeps the workflow self-contained. Without that flag, init tries to configure the S3 backend and needs state bucket access to proceed. With it, Terraform initializes locally, downloads provider schemas from the registry, and skips remote state entirely, which is all fmt --check and validate actually need.

The first failure out of fmt --check was spacing inside an IAM policy block:

1
2
3
4
╷
│ Error: Files in this directory are not formatted correctly.
│ Run "terraform fmt" to fix them.
╵

Running terraform fmt locally and diffing the result showed inconsistent indentation inside a jsonencode block. The formatter has specific opinions about alignment in those blocks that are easy to get wrong when writing by hand, so running fmt, committing the result, and pushing was all it took to clear it.

TerraformFmt

The Ansible Workflow

ansible-ci.yml triggers on the same conditions, scoped to playbooks/** and roles/**.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# .github/workflows/ansible-ci.yml

name: Ansible CI

on:
  pull_request:
    branches:
      - main
    paths:
      - 'playbooks/**'
      - 'roles/**'

permissions:
  id-token: write
  contents: read

jobs:
  ansible-lint:
    name: Ansible Lint
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.12'

      - name: Install ansible-lint and collections
        run: |
          pip install ansible-lint
          ansible-galaxy collection install community.general
          ansible-galaxy collection install ansible.posix

      - name: Run ansible-lint
        run: ansible-lint playbooks/ roles/

The install step is where most of the first-run failure came from. The roles and playbooks reference modules from community.general and ansible.posix, and if those collections aren’t installed in the runner environment, the linter flags them as fatal violations rather than warnings. The runner has no awareness of what’s installed locally, so the collections have to be declared and installed explicitly as part of the job, which nothing in the ansible-lint documentation makes particularly obvious upfront.

The first run produced this:

AnsibleLintManyErrors

Two separate problems, and working through the output with Claude clarified what each was enforcing and why they warranted different responses.

The fqcn violations are tasks using short module names instead of fully-qualified collection names:

1
2
3
4
- name: Ensure baseline packages on Debian
  apt:
    name: ""
    state: present

Should be:

1
2
3
4
5
6
- name: Ensure baseline packages on Debian
  ansible.builtin.apt:
    name: "{{ baseline_packages }}"
    state: present

The fully-qualified name makes the module source unambiguous: if a custom collection also ships an apt module, the short name creates a resolution ambiguity the FQCN eliminates. More practically, it’s the convention ansible-lint enforces by default, and the fix across six violations was entirely mechanical.

package-latest warranted a different response. The update tasks use state: latest, which ansible-lint flags by default because it can cause uncontrolled version drift if a package updates to something that breaks the system. That concern is real in production, but in a lab that exists specifically to apply updates across distributions, keeping packages current is the intent, not an oversight. Claude walked through why noqa was the right call here rather than reworking the task logic: suppress the specific rule, leave everything else linted normally, and leave a comment documenting that the decision was deliberate.

1
2
3
4
5
6
7
8
- name: Update all packages on Debian/Ubuntu
  ansible.builtin.apt:
    upgrade: dist
    update_cache: true
  when: ansible_os_family == "Debian"
  # noqa: package-latest

Someone reading the file later shouldn’t have to wonder whether the rule was forgotten or ignored. After qualifying the module names and adding the noqa tags, the workflow passed cleanly.

AllGreenToGo

What the Pipeline Does Not Do

Neither workflow validates that the playbooks would actually run successfully against real infrastructure. ansible-lint checks syntax, module usage, and style but does not execute tasks, and the Terraform workflow validates the configuration graph and checks formatting without running plan or apply. Nothing in the pipeline connects to a live instance.

That scope is deliberate: a pipeline that requires live AWS resources on every PR adds provisioning time, cost, and dependency on infrastructure state to what should be a fast feedback loop. The static analysis catches the category of errors it’s designed to catch, style violations, missing collections, formatting inconsistencies, configuration graph problems, and execution-time failures get caught by running the actual code against the actual lab, which is what the lab is for.

Where This Leaves It

Before these workflows existed, a PR with broken Terraform formatting or an unqualified module name would merge without complaint and I’d find out whenever I happened to run things locally. Now it fails a check within a couple of minutes, which is the whole point. The habit of making that feedback loop automatic is easier to build before you need it than after you already have a codebase that assumes you won’t.

The full configuration is at orionilloc/ansible-linux-sandbox: OIDC-authenticated CI, dynamic inventory, and roles for baseline configuration and OS hardening across six distributions. Ansible Vault, Molecule, and the compliance automation that OpenSCAP makes possible are all reasonable next directions, though they’re genuinely different problems from the one this series was solving.

This post is licensed under CC BY 4.0 by the author.