Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
name: CI

on:
push:
branches:
- main
pull_request:
branches:
- main

permissions: {}

jobs:
lint:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Set tool versions
uses: wistia/parse-tool-versions@32f568a4ffd4bfa7720ebf93f171597d1ebc979a # v2.1.1
with:
postfix: _TOOL_VERSION

- uses: j178/prek-action@4e14d07f9231acabce116ccfca13b13dd9755ece # v3.0.0
with:
# Without a pin this installs whatever prek released most recently,
# so a hostile release reaches CI with no change here. The version
# comes from .tool-versions, so CI and a local run agree on it.
prek-version: ${{ env.PREK_TOOL_VERSION }}
env:
# zizmor's five online audits skip silently without a token, and its
# summary line reads the same either way, so CI would look green with
# the supply-chain audits never having run.
GH_TOKEN: ${{ github.token }}

test:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Run the tests
run: python3 -m unittest discover -s validate-min-terraform -p 'test_*.py'
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
__pycache__/
21 changes: 21 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
repos:
- repo: https://github.com/rhysd/actionlint
rev: 393031adb9afb225ee52ae2ccd7a5af5525e03e8 # frozen: v1.7.11
hooks:
- id: actionlint

- repo: https://github.com/zizmorcore/zizmor-pre-commit
rev: ea2eb407b4cbce87cf0d502f36578950494f5ac9 # frozen: v1.23.1
hooks:
- id: zizmor

- repo: https://github.com/astral-sh/ruff-pre-commit
rev: f12be1ebaa5351c1fc76472de98db2c3446c8253 # frozen: v0.16.10
hooks:
- id: ruff-check
- id: ruff-format

- repo: https://github.com/codespell-project/codespell
rev: 2ccb47ff45ad361a21071a7eedda4c37e6ae8c5a # frozen: v2.4.2
hooks:
- id: codespell
3 changes: 3 additions & 0 deletions .tool-versions
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
just 1.40.0

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@johnsca this is very old - you may as well update it before shipping this? same with uv.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated here and in the five module repositories: just 1.58.0, uv 0.12.23, and in the modules terraform 1.16.5 and tflint 0.64.0. just lint passes at all of them.

terraform-docs stays at 0.20.0. The docs job runs terraform-docs/gh-actions v1.4.1, which builds FROM quay.io/terraform-docs/terraform-docs:0.20.0. Moving it would have CI check the README against a different renderer than just docs uses.

The image this action parses with stays there too. 0.20.0 is the last release published as a multi-arch index, and later tags are per-architecture, so a digest pin would have to pick one.

Filed by Claude on Cory's behalf.

prek 0.5.5
uv 0.8.14
24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,24 @@
# github-tooling
Reusable GitHub Actions and workflows shared across Stacklet repositories

Reusable GitHub Actions and workflows shared across Stacklet repositories.

Each tool lives in its own top-level directory with an `action.yml` and a `README.md`. Reference one by path:

```yaml
- uses: stacklet/github-tooling/<tool>@<commit-sha>
```

Pin by commit SHA. This repository publishes no release tags, so a SHA is the only reference that cannot move.

## Tools

| Tool | Purpose |
| --- | --- |
| [validate-min-terraform](validate-min-terraform/) | Runs `terraform validate` at the oldest release a module's `required_version` allows. |
Comment thread
johnsca marked this conversation as resolved.
Resolved

## Adding a tool

1. Create a directory named for the tool.
2. Add an `action.yml` and a `README.md` covering usage, inputs, and outputs.
3. Keep any non-trivial logic in its own script beside the `action.yml`, so that a linter reads it and a test can run it without a runner. Prefer Python over shell for anything that parses or compares.
4. Add a row to the table above.
7 changes: 7 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# lint files
lint:
prek run --all-files

# run the tests
test:
python3 -m unittest discover -s validate-min-terraform -p 'test_*.py'
72 changes: 72 additions & 0 deletions validate-min-terraform/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# validate-min-terraform

Runs `terraform validate` against a module at the oldest Terraform release the module says it supports.

A repository's lint job validates at the release pinned in `.tool-versions`, which is near the top of the supported range. Nothing covers the bottom of the range, so a module can declare `required_version = ">= 1.0"` and use syntax that no release below 1.8 parses. This action closes that gap.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@johnsca to be honest, given https://endoflife.date/terraform and terraform's relatively short support lifecycle, I'm left wondering whether we might not be better off simply enforcing a modern terraform minimum floor?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The official support info from HashiCorp has 1.14.x getting fixes and security patches though August of next year, per the Support Lifecycle Addendum. Which is a bit complicated, so we could instead just follow the 2 year primary LTS window.

Should I change the action to figure out the minimum version based on the 2-year LTS window and validate with that and that the constraint falls within that LTS window instead? That would force us to keep it up-to-date instead of letting it drift like we had.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping it a literal rather than deriving the window at run time.

Deriving it means a network call to HashiCorp on every run, a new failure mode when that endpoint moves or rate-limits, and a check whose result changes with no commit to point at. A module that passes on Monday fails on Tuesday because a window rolled.

The literal is 1.14.0, with the reasoning and its ambiguity in the README beside it. If drift is the worry, a scheduled job that opens a pull request raising the default keeps the change reviewable.

Filed by Claude on Cory's behalf.


## Usage

```yaml
jobs:
validate-min-terraform:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- uses: stacklet/github-tooling/validate-min-terraform@<commit-sha>
```

## Inputs

| Name | Default | Description |
| --- | --- | --- |
| `working-directory` | `.` | Directory holding the module to validate. |
| `minimum-floor` | `1.2.2` | Refuse to run below this release. |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@johnsca Shouldn't the minimum floor be something supported and modern? The version you mention has been end of life for over 3 years and has a ton of security problems.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and changed. The default is now 1.14.0 rather than 1.2.2.

1.2.2 cleared one advisory, the go-getter issues reachable while init fetches modules. Nothing else recommended it.

1.14 rests on a judgement call. HashiCorp publishes no support window for the community CLI. Its support policy routes a version to a regime by release date, and 1.14 lands in the band still receiving fixes and security patches. The Support Lifecycle Addendum that band points at lists only Self-Managed products, so no row names a CLI line. We read the routing table as the applicable statement, which puts 1.14 at the oldest release with a published claim to fixes. If you read it differently, the input exists and the number is one line.

The README records that reasoning, and says the default wants revisiting whenever a new minor lands.

Filed by Claude on Cory's behalf.

| `terraform-docs-image` | a digest-pinned terraform-docs 0.20.0 | Image used to parse the configuration. |

## Outputs

| Name | Description |
| --- | --- |
| `version` | The Terraform release the module declares as its floor. |

## How the floor is derived

`terraform-docs` parses the configuration and reports every `required_version` in the directory. Terraform applies all of them at once, so the effective floor is the highest lower bound among them. Only `>=` and `~>` state that bound literally, and `<` and `<=` cannot move it.

The action fails rather than guessing on anything else:

- A constraint with no lower bound, such as `< 2.0.0` alone, names no floor to test.
- `!=` and `=` can rule out the bound another term states, which would otherwise install a release the module excludes.
- `>` is exclusive, so it states no release to install. Write `>= 1.14.1` rather than `> 1.14.0`.
Comment thread
johnsca marked this conversation as resolved.
Resolved

The action reads one directory. A repository with modules in subdirectories needs one call per directory, which a matrix over `working-directory` covers.

Running a real parser matters. A regex over `*.tf` misses a constraint in a `.tf.json`, misses a second `.tf` file, and reads a constraint split across lines as empty, which several published actions then treat as "any version".

The terraform-docs image is pinned by digest because the project publishes no immutable releases, so both a tag and a release checksum can change underneath us. Bumping the version means bumping the digest in `action.yml`.

## Why there is a floor on the floor

The release this installs comes out of the configuration under test, so on a pull request the branch decides which Terraform runs in CI. That is the point of the action, and it is also a way to reach an old release for the sake of its bugs rather than for support. `terraform init` installs providers and fetches module sources whatever `-backend=false` says, and Terraform 1.0.0 through 1.2.1 bundle a go-getter affected by HCSEC-2022-13, which HashiCorp never backported to those lines.

`minimum-floor` defaults to 1.2.2, the first release past that. Raise it to your own support floor if you would rather not run old releases at all.

Two things this does not solve. A pull request still chooses which providers `init` installs, and a provider plugin is a binary that `validate` executes to read its schema, so the job must hold no secrets and no write permission. The usage example above is the shape to keep. Committing `.terraform.lock.hcl` would pin provider checksums, but a pull request can edit the lock file in the same commit, so it narrows the window rather than closing it.

## Requirements

The runner needs Docker, since the parse runs in a container, and `python3`, which reads the result. `ubuntu-latest` has both. The macOS runners have no Docker.

## Tests

`just test` runs `test_read_floor.py`, and CI runs the same command. The constraint cases call the parser directly and need no container, so adding one for a constraint form the parser learns to read or to reject costs nothing. Only the cases that turn on what terraform-docs reports run the script end to end, against the fixtures under `testdata/`.

## Known limitation

`terraform validate` run against a module directory treats every root variable as unknown, so it never evaluates `validation` blocks. A module whose floor is wrong only inside a variable validation still passes here. Closing that needs `terraform test` with `mock_provider`.
65 changes: 65 additions & 0 deletions validate-min-terraform/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
name: Validate the declared Terraform floor
description: >-
Reads the Terraform floor out of a module's required_version and runs
terraform validate at that release.

inputs:
working-directory:
description: Directory holding the Terraform module to validate.
required: false
default: .
minimum-floor:
description: >-
Refuse to run below this release. The floor comes out of the
configuration under test, so on a pull request the branch decides which
Terraform CI installs.
required: false
# Terraform 1.2.2 is the first release past HCSEC-2022-13, which covers
# four go-getter issues reachable while init fetches modules. HashiCorp
# never backported the fix to the 1.0 or 1.1 lines. Raise this to your own
# support floor if you would rather not run old releases at all.
default: 1.2.2
terraform-docs-image:
description: >-
Image used to parse the configuration. Pin by digest, not by tag.
required: false
# terraform-docs publishes no immutable releases, which leaves both a tag
# and a release checksum able to change underneath us. A digest is content
# addressed, so it cannot. This one is terraform-docs 0.20.0.
default: quay.io/terraform-docs/terraform-docs@sha256:37329e2dc2518e7f719a986a3954b10771c3fe000f50f83fd4d98d489df2eae2

outputs:
version:
description: The Terraform release the module declares as its floor.
value: ${{ steps.floor.outputs.version }}

runs:
using: composite
steps:
# The floor is read out of required_version rather than passed in, so that
# editing the constraint moves what CI tests in the same commit.
# terraform-docs parses the configuration instead of matching a regex
# against one file, so it reports every required_version in the directory,
# including one in a .tf.json or split across lines.
- name: Read the declared Terraform floor
id: floor
shell: bash

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@johnsca Hmm, why?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hold-over from pre-refactor.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A composite step has to declare a shell, so shell: bash itself cannot go. The hold-over was the logic on that line.

read_floor.py now writes version= to $GITHUB_OUTPUT itself, so the step is a bare invocation of the script. A test covers the file contents.

Filed by Claude on Cory's behalf.

working-directory: ${{ inputs.working-directory }}
env:
TERRAFORM_DOCS_IMAGE: ${{ inputs.terraform-docs-image }}
MINIMUM_FLOOR: ${{ inputs.minimum-floor }}
run: |
floor=$("$GITHUB_ACTION_PATH/read_floor.py")
echo "version=$floor" >> "$GITHUB_OUTPUT"

- name: Setup Terraform
uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
with:
terraform_version: ${{ steps.floor.outputs.version }}

- name: Validate at the declared floor
shell: bash
working-directory: ${{ inputs.working-directory }}
run: |
terraform init -backend=false -input=false
terraform validate
141 changes: 141 additions & 0 deletions validate-min-terraform/read_floor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""Print the oldest Terraform release the module in the working directory supports.

The floor is read out of the module's required_version constraints rather than
passed in, so that editing a constraint moves what CI tests in the same commit.
"""

import json
import os
import re
import subprocess
import sys

# >= and ~> are the only operators that state a lower bound literally. < and <=
# cannot move the floor. Anything else either states no bound at all or can rule
# out the bound another term states, and resolving that needs the release list.
LOWER_BOUND = re.compile(r"^(?:>=|~>)\s*(\d+(?:\.\d+){0,2})$")
UPPER_BOUND = re.compile(r"^<=?\s*\S+$")


class FloorError(Exception):
"""A constraint this cannot resolve a floor from."""


def parse_version(text):
"""Read a version as a comparable tuple.

Terraform reads a short bound as the lowest release matching it, so >= 1
means 1.0.0.
"""
parts = [int(part) for part in text.split(".")]
return tuple(parts + [0] * (3 - len(parts)))


def format_version(version):
return ".".join(str(part) for part in version)


def lower_bounds(constraints):
"""Yield the lower bound stated by each term of each constraint."""
for constraint in constraints:
for term in constraint.split(","):
term = term.strip()
if not term:
continue
match = LOWER_BOUND.match(term)
if match:
yield parse_version(match.group(1))
elif not UPPER_BOUND.match(term):
raise FloorError(
f"cannot resolve a Terraform floor from: {term}\n"
"required_version may use >=, ~>, < and <= only"
)


def derive_floor(constraints, minimum):
"""Return the release to install, given every constraint in the directory.

Terraform applies all of them at once, so the effective floor is the highest
lower bound among them.
"""
if not constraints:
raise FloorError("this module declares no required_version")

bounds = list(lower_bounds(constraints))
if not bounds:
found = "\n".join(f" {constraint}" for constraint in constraints)
raise FloorError(f"required_version states no lower bound; found:\n{found}")

floor = max(bounds)
# The floor comes out of the configuration under test, and on a pull request
# that configuration is whatever the pull request says. Without a clamp the
# branch picks which Terraform release CI runs, which is a way to reach an
# old release for the sake of its bugs rather than for support.
if floor < minimum:
raise FloorError(
f"declared floor {format_version(floor)} is below the minimum "
f"this action will run: {format_version(minimum)}"
)
return floor


def read_constraints(image, directory):
"""Return every Terraform required_version terraform-docs finds in a directory.

--show overrides a .terraform-docs.yml that hides the requirements section,
which would otherwise report a module that declares a constraint as one that
declares none. --output-file keeps terraform-docs from writing its render
into the mounted directory under a config-supplied output.file.
"""
result = subprocess.run(
[
Comment thread
johnsca marked this conversation as resolved.
Resolved
"docker",
"run",
"--rm",
"--network",
"none",
"-u",
f"{os.getuid()}:{os.getgid()}",
"-v",
f"{directory}:/wd:ro",
"-w",
"/wd",
image,
"json",
".",
"--output-file",
"",
"--show",
"requirements",
],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
raise FloorError(
f"terraform-docs could not read this module:\n{result.stderr.rstrip()}"
)
requirements = json.loads(result.stdout).get("requirements") or []
return [
requirement["version"]
for requirement in requirements
if requirement.get("name") == "terraform"
]


def main():
image = os.environ["TERRAFORM_DOCS_IMAGE"]
minimum = parse_version(os.environ["MINIMUM_FLOOR"])
try:
constraints = read_constraints(image, os.getcwd())
print(format_version(derive_floor(constraints, minimum)))
except FloorError as error:
print(error, file=sys.stderr)
return 1
return 0


if __name__ == "__main__":
sys.exit(main())
Loading
Loading