diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..6931f70 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @stacklet/engineering diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f1ec714 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 pass while the + # supply-chain audits never ran. + 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' diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..44024de --- /dev/null +++ b/.pre-commit-config.yaml @@ -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 diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..9373257 --- /dev/null +++ b/.tool-versions @@ -0,0 +1,3 @@ +just 1.58.0 +prek 0.5.5 +uv 0.12.23 diff --git a/README.md b/README.md index 89d40ee..967c90c 100644 --- a/README.md +++ b/README.md @@ -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/@ +``` + +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 floor a module's `required_version` declares, and fails when that floor is below a configured minimum. | + +## 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. diff --git a/justfile b/justfile new file mode 100644 index 0000000..35984f2 --- /dev/null +++ b/justfile @@ -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' diff --git a/validate-min-terraform/README.md b/validate-min-terraform/README.md new file mode 100644 index 0000000..2ea3eec --- /dev/null +++ b/validate-min-terraform/README.md @@ -0,0 +1,69 @@ +# validate-min-terraform + +Runs `terraform validate` against a Terraform module at the oldest release its +`required_version` allows, so the bottom of the declared range gets tested +rather than assumed. + +## 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@ +``` + +> [!WARNING] +> `terraform init` installs the providers the module names, and `validate` runs +> each provider binary to read its schema. On a pull request that configuration +> is untrusted, so give the job `contents: read`, and no secrets. + +## Inputs + +| Name | Default | Description | +| --- | --- | --- | +| `working-directory` | `.` | Directory holding the module. One directory per call, with no descent into subdirectories. | +| `minimum-floor` | `1.14.0` | Refuse to run below this release. The default is the oldest release HashiCorp still ships fixes for. | +| `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. | + +## Requirements + +The runner needs Docker and `python3`. `ubuntu-latest` has both, and the macOS +runners have no Docker. + +## Version constraints + +`required_version` must state a lower bound with `>=`, `~>` or `=`, or name an +exact version such as `1.14.0`. Upper bounds are allowed and ignored, as is +`!=` unless it excludes the floor itself. The action rejects any constraint it +cannot resolve to a definite release, and fails when the floor it resolves +falls below `minimum-floor`. + +Every `required_version` in the directory counts, including one in a `.tf.json` +or split across lines. The floor is the highest lower bound among them, which +is what Terraform itself applies. + +## Known limitation + +> [!NOTE] +> `terraform validate` treats every root variable as unknown, so it never +> evaluates `validation` blocks. A floor that is wrong only inside a variable +> validation still passes here. + +## Tests + +`just test` runs `test_read_floor.py`, and CI runs the same command. diff --git a/validate-min-terraform/action.yml b/validate-min-terraform/action.yml new file mode 100644 index 0000000..49c4b4b --- /dev/null +++ b/validate-min-terraform/action.yml @@ -0,0 +1,67 @@ +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 + # HashiCorp routes a version to a support regime by release date, and 1.14 + # falls in the band still receiving fixes and security patches, so this is + # the oldest release with a published claim to them. It wants revisiting + # when a new minor lands. See the README. + default: 1.14.0 + 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, the last + # release published as a multi-arch index. Later tags are per-architecture, + # so a digest pin would have to choose one. + 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 + working-directory: ${{ inputs.working-directory }} + env: + TERRAFORM_DOCS_IMAGE: ${{ inputs.terraform-docs-image }} + MINIMUM_FLOOR: ${{ inputs.minimum-floor }} + # The script writes the step output itself, so this step carries no shell + # logic. A composite step has to name a shell even so. + run: '"$GITHUB_ACTION_PATH/read_floor.py"' + + - 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 diff --git a/validate-min-terraform/read_floor.py b/validate-min-terraform/read_floor.py new file mode 100755 index 0000000..e4d60fd --- /dev/null +++ b/validate-min-terraform/read_floor.py @@ -0,0 +1,151 @@ +#!/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 shlex +import subprocess +import sys + +# >=, ~> and = state a lower bound literally, as does a bare version, which +# Terraform reads as an exact pin. < and <= cannot move the floor, and != only +# matters when it removes the floor another term states. Anything else needs the +# release list to resolve, which this does not read. +LOWER_BOUND = re.compile(r"^(?:>=|~>|=)?\s*(\d+(?:\.\d+){0,2})$") +UPPER_BOUND = re.compile(r"^<=?\s*\S+$") +EXCLUSION = re.compile(r"^!=\s*(\d+(?:\.\d+){0,2})$") + + +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 read_terms(constraints): + """Return the lower bounds stated across every constraint, and the exclusions.""" + bounds = [] + excluded = set() + for constraint in constraints: + for term in constraint.split(","): + term = term.strip() + if not term: + continue + bound = LOWER_BOUND.match(term) + exclusion = EXCLUSION.match(term) + if bound: + bounds.append(parse_version(bound.group(1))) + elif exclusion: + excluded.add(parse_version(exclusion.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" + ) + return bounds, excluded + + +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, excluded = read_terms(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) + if floor in excluded: + raise FloorError( + f"required_version excludes {format_version(floor)}, the floor it " + "states. Resolving the next release above it needs the release list." + ) + + # 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( + shlex.split( + "docker run --rm --network none" + f" -u {os.getuid()}:{os.getgid()}" + f" -v {shlex.quote(f'{directory}:/wd:ro')} -w /wd {shlex.quote(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()) + floor = format_version(derive_floor(constraints, minimum)) + except FloorError as error: + print(error, file=sys.stderr) + return 1 + + print(floor) + # This keeps the shell logic out of the action. Outside a workflow the + # variable is unset and stdout carries the whole result, which is what the + # tests read. + output = os.environ.get("GITHUB_OUTPUT") + if output: + with open(output, "a") as handle: + handle.write(f"version={floor}\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/validate-min-terraform/test_read_floor.py b/validate-min-terraform/test_read_floor.py new file mode 100755 index 0000000..abc1eda --- /dev/null +++ b/validate-min-terraform/test_read_floor.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""Exercise read_floor.py. + +The constraint cases run against the parser directly, so they need no container. +The cases that turn on what terraform-docs reports run the script against the +fixtures under testdata/. +""" + +import os +import pathlib +import re +import subprocess +import tempfile +import unittest + +import read_floor + +HERE = pathlib.Path(__file__).parent +DEFAULT_MINIMUM = (1, 14, 0) + +# The image the action defaults to, so the tests and the action cannot drift. +IMAGE = os.environ.get("TERRAFORM_DOCS_IMAGE") or re.search( + r"default: (quay\.io/terraform-docs/\S+)", (HERE / "action.yml").read_text() +).group(1) + + +RESOLVES = [ + ([">= 1.14.0"], "1.14.0"), + ([">= 1.14.0, < 2.0.0"], "1.14.0"), + (["~> 1.14"], "1.14.0"), + ([">= 1.9, >= 1.14.0, < 2.0.0"], "1.14.0"), + ([">= 1.15.0"], "1.15.0"), + # Terraform applies every constraint in the directory at once. + ([">= 1.99.0", ">= 1.2.0", ">= 1.14.0"], "1.99.0"), + # An exact pin states its own floor, alone or beside a lower bound. + (["= 1.14.0"], "1.14.0"), + (["=1.15.0"], "1.15.0"), + ([">= 1.14.0, = 1.15.0"], "1.15.0"), + # Terraform reads a bare version as an exact pin. + (["1.14.0"], "1.14.0"), + # An exclusion that leaves the floor alone does not stop the resolution. + ([">= 1.14.0, != 1.14.2"], "1.14.0"), + # A short bound is the lowest release matching it. + ([">= 1"], "1.0.0"), + (["~> 1"], "1.0.0"), +] + +REJECTS = [ + # No lower bound to test. + (["< 2.0.0"], "states no lower bound"), + (["<= 2.0.0"], "states no lower bound"), + # An exclusion that removes the floor the other term states. + ([">= 1.14.0, != 1.14.0"], "excludes 1.14.0"), + # Exclusive, so it names no release to install. + (["> 1.14.0"], "cannot resolve"), + ([">= 1.14.0-beta1"], "cannot resolve"), + ([], "declares no required_version"), +] + + +class DeriveFloorTest(unittest.TestCase): + def test_resolves(self): + for constraints, expected in RESOLVES: + with self.subTest(constraints=constraints): + minimum = (1, 0, 0) if expected == "1.0.0" else DEFAULT_MINIMUM + floor = read_floor.derive_floor(constraints, minimum) + self.assertEqual(read_floor.format_version(floor), expected) + + def test_rejects(self): + for constraints, message in REJECTS: + with self.subTest(constraints=constraints): + with self.assertRaises(read_floor.FloorError) as caught: + read_floor.derive_floor(constraints, DEFAULT_MINIMUM) + self.assertIn(message, str(caught.exception)) + + def test_a_floor_below_the_minimum_is_refused(self): + with self.assertRaises(read_floor.FloorError) as caught: + read_floor.derive_floor([">= 1.13.0"], DEFAULT_MINIMUM) + self.assertIn("below the minimum", str(caught.exception)) + + def test_the_minimum_itself_is_allowed(self): + floor = read_floor.derive_floor([">= 1.14.0"], DEFAULT_MINIMUM) + self.assertEqual(floor, DEFAULT_MINIMUM) + + +class ReadFloorScriptTest(unittest.TestCase): + """End to end, including the terraform-docs run. These need Docker.""" + + def run_script(self, directory, minimum="1.14.0", output=None): + return subprocess.run( + [str(HERE / "read_floor.py")], + cwd=directory, + capture_output=True, + text=True, + check=False, + env={ + **os.environ, + "TERRAFORM_DOCS_IMAGE": IMAGE, + "MINIMUM_FLOOR": minimum, + **({"GITHUB_OUTPUT": str(output)} if output else {}), + }, + ) + + def test_it_writes_the_step_output(self): + """The action reads the floor from GITHUB_OUTPUT, not from stdout.""" + with tempfile.TemporaryDirectory() as work: + output = pathlib.Path(work) / "output" + output.touch() + result = self.run_script( + HERE / "testdata" / "multiline-constraint", output=output + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(output.read_text(), "version=1.14.0\n") + + def test_several_files_in_one_directory(self): + """The highest bound wins, and here it is the one in the .tf.json.""" + result = self.run_script(HERE / "testdata" / "several-files") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.strip(), "1.99.0") + + def test_a_constraint_split_across_lines_decides(self): + """The multiline form is the one a regex over the file reads as empty.""" + result = self.run_script(HERE / "testdata" / "multiline-constraint") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.strip(), "1.14.0") + + def test_a_config_hiding_requirements(self): + """A module that declares a constraint must not look like one that does not.""" + result = self.run_script(HERE / "testdata" / "hidden-requirements") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.strip(), "1.14.0") + + def test_a_floor_below_the_minimum_is_refused(self): + result = self.run_script( + HERE / "testdata" / "multiline-constraint", minimum="1.15.0" + ) + self.assertEqual(result.returncode, 1) + self.assertIn("below the minimum", result.stderr) + + def test_no_required_version(self): + result = self.run_script(HERE / "testdata" / "no-constraint") + self.assertEqual(result.returncode, 1) + self.assertIn("declares no required_version", result.stderr) + + def test_a_container_that_cannot_run_says_so(self): + result = self.run_script(HERE / "testdata" / "several-files") + self.assertEqual(result.returncode, 0, result.stderr) + broken = subprocess.run( + [str(HERE / "read_floor.py")], + cwd=HERE / "testdata" / "several-files", + capture_output=True, + text=True, + check=False, + env={ + **os.environ, + "TERRAFORM_DOCS_IMAGE": "quay.io/terraform-docs/terraform-docs:no-such-tag", + "MINIMUM_FLOOR": "1.14.0", + }, + ) + self.assertEqual(broken.returncode, 1) + self.assertIn("could not read this module", broken.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/validate-min-terraform/testdata/hidden-requirements/.terraform-docs.yml b/validate-min-terraform/testdata/hidden-requirements/.terraform-docs.yml new file mode 100644 index 0000000..20858f1 --- /dev/null +++ b/validate-min-terraform/testdata/hidden-requirements/.terraform-docs.yml @@ -0,0 +1,4 @@ +formatter: markdown + +sections: + hide: [requirements] diff --git a/validate-min-terraform/testdata/hidden-requirements/main.tf b/validate-min-terraform/testdata/hidden-requirements/main.tf new file mode 100644 index 0000000..48f553e --- /dev/null +++ b/validate-min-terraform/testdata/hidden-requirements/main.tf @@ -0,0 +1,3 @@ +terraform { + required_version = ">= 1.14.0" +} diff --git a/validate-min-terraform/testdata/multiline-constraint/main.tf b/validate-min-terraform/testdata/multiline-constraint/main.tf new file mode 100644 index 0000000..b758363 --- /dev/null +++ b/validate-min-terraform/testdata/multiline-constraint/main.tf @@ -0,0 +1,4 @@ +terraform { + required_version = + ">= 1.14.0" +} diff --git a/validate-min-terraform/testdata/multiline-constraint/other.tf b/validate-min-terraform/testdata/multiline-constraint/other.tf new file mode 100644 index 0000000..12ad22a --- /dev/null +++ b/validate-min-terraform/testdata/multiline-constraint/other.tf @@ -0,0 +1,3 @@ +terraform { + required_version = ">= 1.3.0" +} diff --git a/validate-min-terraform/testdata/no-constraint/main.tf b/validate-min-terraform/testdata/no-constraint/main.tf new file mode 100644 index 0000000..6b89823 --- /dev/null +++ b/validate-min-terraform/testdata/no-constraint/main.tf @@ -0,0 +1,3 @@ +output "a" { + value = 1 +} diff --git a/validate-min-terraform/testdata/several-files/extra.tf.json b/validate-min-terraform/testdata/several-files/extra.tf.json new file mode 100644 index 0000000..c37c280 --- /dev/null +++ b/validate-min-terraform/testdata/several-files/extra.tf.json @@ -0,0 +1 @@ +{"terraform": [{"required_version": ">= 1.99.0"}]} diff --git a/validate-min-terraform/testdata/several-files/main.tf b/validate-min-terraform/testdata/several-files/main.tf new file mode 100644 index 0000000..465d1d0 --- /dev/null +++ b/validate-min-terraform/testdata/several-files/main.tf @@ -0,0 +1,4 @@ +terraform { + required_version = + ">= 1.2.0" +} diff --git a/validate-min-terraform/testdata/several-files/provider.tf b/validate-min-terraform/testdata/several-files/provider.tf new file mode 100644 index 0000000..48f553e --- /dev/null +++ b/validate-min-terraform/testdata/several-files/provider.tf @@ -0,0 +1,3 @@ +terraform { + required_version = ">= 1.14.0" +}