diff --git a/README.md b/README.md index 12ccaa7d6..eda9fa710 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ Linters which are not language-specific: | Go | [gofmt] or [gofumpt] | | | Gherkin | [prettier-plugin-gherkin] | | | GraphQL | [Prettier] | | -| HCL (Hashicorp Config) | [terraform] fmt | | +| HCL (Hashicorp Config) | [terraform] fmt | [tflint] | | HTML | [Prettier] | | | JSON | [Prettier] | | | Java | [google-java-format] | [pmd] , [Checkstyle], [Spotbugs] | @@ -76,6 +76,7 @@ Linters which are not language-specific: [eslint]: https://eslint.org/ [swiftformat]: https://github.com/nicklockwood/SwiftFormat [terraform]: https://github.com/hashicorp/terraform +[tflint]: https://github.com/terraform-linters/tflint [buf]: https://docs.buf.build/format/usage [keep-sorted]: https://github.com/google/keep-sorted [ktfmt]: https://github.com/facebook/ktfmt diff --git a/examples/README.md b/examples/README.md index 36e0d87f1..ff5a9023f 100644 --- a/examples/README.md +++ b/examples/README.md @@ -24,6 +24,7 @@ Each example is self-contained and shows: - `proto/` - Protocol Buffer formatting and linting with Buf - `starlark/` - Starlark formatting with Buildifier; linting with Buildifier - `qml/` - QML formatting with qmlformat; linting with qmllint +- `terraform/` - Terraform formatting with terraform fmt; linting with tflint ## Multi-language Example diff --git a/examples/terraform/.bazelrc b/examples/terraform/.bazelrc new file mode 100644 index 000000000..680843042 --- /dev/null +++ b/examples/terraform/.bazelrc @@ -0,0 +1 @@ +build:lint --aspects=//tools/lint:linters.bzl%tflint diff --git a/examples/terraform/.tflint.hcl b/examples/terraform/.tflint.hcl new file mode 100644 index 000000000..b040f1ffd --- /dev/null +++ b/examples/terraform/.tflint.hcl @@ -0,0 +1,12 @@ +# Plugins are pre-fetched by the tflint_plugin repository rule and placed +# in TFLINT_PLUGIN_DIR. source/version are omitted here so tflint discovers +# plugins by name without needing --init or network access. + +plugin "terraform" { + enabled = true + preset = "recommended" +} + +plugin "google" { + enabled = true +} diff --git a/examples/terraform/BUILD b/examples/terraform/BUILD new file mode 100644 index 000000000..c076d313f --- /dev/null +++ b/examples/terraform/BUILD @@ -0,0 +1,3 @@ +package(default_visibility = ["//visibility:public"]) + +exports_files([".tflint.hcl"]) diff --git a/examples/terraform/MODULE.aspect b/examples/terraform/MODULE.aspect new file mode 100644 index 000000000..91eea2c1c --- /dev/null +++ b/examples/terraform/MODULE.aspect @@ -0,0 +1,9 @@ +# Aspect Extension setup for CLI "lint" and "format" commands +# This example is in rules_lint, so we use a local dependency. +# See https://github.com/bazel-starters/terraform/blob/main/MODULE.aspect +# for a real-world example. +axl_local_dep( + name = "rules_lint", + path = "../..", + auto_use_tasks = True, +) diff --git a/examples/terraform/MODULE.bazel b/examples/terraform/MODULE.bazel index 8eae69d41..d701b67e6 100644 --- a/examples/terraform/MODULE.bazel +++ b/examples/terraform/MODULE.bazel @@ -1,7 +1,25 @@ -"Bazel dependencies for Terraform formatting example" +"Bazel dependencies for Terraform formatting and linting example" bazel_dep(name = "aspect_rules_lint") +bazel_dep(name = "jq.bzl", version = "0.6.0") + local_path_override( module_name = "aspect_rules_lint", path = "../..", ) + +# Demonstrate fetching a tflint plugin via the tflint_plugin repository rule. +# The google plugin validates Google Cloud-specific Terraform resources. +tflint_ext = use_extension("@aspect_rules_lint//lint:tflint_plugins.bzl", "tflint_ext") +tflint_ext.plugin( + name = "tflint_plugin_google", + ruleset_name = "google", + sha256s = { + "linux_amd64": "e5034d2c25c2ed94b131897904614ea8b575fb1fc7a0619b14a874d8cb9adec1", + "linux_arm64": "e7e387b7ed4ee02c81efaaab87c47af69377a2a1809e579e357d0965fb524299", + "darwin_amd64": "2d393ac529b285c29d5818272ab9df9a9af3c6d1173ab642bd42d0a8b7cfe606", + "darwin_arm64": "96fb99f49b25b54000729da9f396b14ef885c2204e3db2e0414a7f21578fd6fc", + }, + url_template = "https://github.com/terraform-linters/tflint-ruleset-google/releases/download/v0.39.0/tflint-ruleset-google_{platform}.zip", +) +use_repo(tflint_ext, "tflint_plugin_google") diff --git a/examples/terraform/README.md b/examples/terraform/README.md index 48f6492e2..4d113294e 100644 --- a/examples/terraform/README.md +++ b/examples/terraform/README.md @@ -1,6 +1,6 @@ -# Terraform Formatting Example +# Terraform Example -This example demonstrates how to set up formatting for Terraform (HCL) code using `rules_lint`. +This example demonstrates how to set up formatting and linting for Terraform (HCL) code using `rules_lint`. ## Supported Tools @@ -8,7 +8,9 @@ This example demonstrates how to set up formatting for Terraform (HCL) code usin - **terraform** - Official Terraform formatter -Note: No Terraform linter is currently available in rules_lint. +### Linters + +- **tflint** - Pluggable Terraform linter ## Usage @@ -20,8 +22,16 @@ Format all Terraform files: bazel run //tools/format:format ``` -Format specific files: +### Lint Code + +Lint Terraform files via the aspect: + +```bash +bazel lint //src:example +``` + +Or using vanilla Bazel: ```bash -bazel run //tools/format:format -- hello.tf +bazel build //src:example --aspects=//tools/lint:linters.bzl%tflint --output_groups=rules_lint_human ``` diff --git a/examples/terraform/src/BUILD b/examples/terraform/src/BUILD new file mode 100644 index 000000000..00bb89bd1 --- /dev/null +++ b/examples/terraform/src/BUILD @@ -0,0 +1,7 @@ +package(default_visibility = ["//visibility:public"]) + +filegroup( + name = "example", + srcs = ["main.tf"], + tags = ["lint-with-tflint"], +) diff --git a/examples/terraform/src/main.tf b/examples/terraform/src/main.tf new file mode 100644 index 000000000..77d97d7b7 --- /dev/null +++ b/examples/terraform/src/main.tf @@ -0,0 +1,24 @@ +# This file intentionally contains lint violations: +# - The google provider is missing a version constraint (terraform_required_providers). +# - A required_version attribute is missing (terraform_required_version). +# - The machine_type "invalid-machine-type" does not exist +# (google_compute_instance_invalid_machine_type, from the google plugin). +terraform { + required_providers { + google = { + source = "hashicorp/google" + } + } +} + +resource "google_compute_instance" "example" { + name = "example" + machine_type = "invalid-machine-type" + zone = "us-central1-a" + + boot_disk { + initialize_params { + image = "debian-cloud/debian-11" + } + } +} diff --git a/examples/terraform/test/BUILD b/examples/terraform/test/BUILD new file mode 100644 index 000000000..b4a3a8ee6 --- /dev/null +++ b/examples/terraform/test/BUILD @@ -0,0 +1,37 @@ +# buildifier: disable=bzl-visibility +load("@aspect_rules_lint//lint/private:machine_report_testing.bzl", "PHYSICAL_ARTIFACT_LOCATION_URI_FILTER") +load("@jq.bzl//jq:jq.bzl", "jq_test") +load("//test:machine_output.bzl", "machine_tflint_report") +load("//tools/lint:linters.bzl", "tflint_test") + +package(default_visibility = ["//visibility:public"]) + +tflint_test( + name = "tflint", + srcs = ["//src:example"], + expected_exit_code = 2, +) + +machine_tflint_report( + name = "machine_tflint_report", + src = "//src:example", +) + +# tflint's native SARIF includes multiple runs (one per ruleset plus a +# "tflint-errors" run for internal errors), so we check .runs[0] rather +# than .runs[] for the tool name. +jq_test( + name = "tflint_machine_output_test", + file1 = "machine_tflint_report", + file2 = "machine_tflint_report", + filter1 = ".runs[0].tool.driver.name", + filter2 = "\"tflint\"", +) + +jq_test( + name = "tflint_machine_output_test_uri", + file1 = "machine_tflint_report", + file2 = "machine_tflint_report", + filter1 = PHYSICAL_ARTIFACT_LOCATION_URI_FILTER, + filter2 = "\"src/main.tf\"", +) diff --git a/examples/terraform/test/lint_test.bats b/examples/terraform/test/lint_test.bats new file mode 100644 index 000000000..81ad3b69f --- /dev/null +++ b/examples/terraform/test/lint_test.bats @@ -0,0 +1,11 @@ +bats_load_library "bats-support" +bats_load_library "bats-assert" + +@test "should produce reports" { + run aspect lint //src:example + assert_success + # The terraform plugin's recommended preset flags missing version constraints + assert_output --partial "terraform_required_providers" + # The google plugin flags the invalid machine type + assert_output --partial "google_compute_instance_invalid_machine_type" +} diff --git a/examples/terraform/test/machine_output.bzl b/examples/terraform/test/machine_output.bzl new file mode 100644 index 000000000..12a55f72f --- /dev/null +++ b/examples/terraform/test/machine_output.bzl @@ -0,0 +1,7 @@ +"Terraform-specific machine report rules for testing." + +# buildifier: disable=bzl-visibility +load("@aspect_rules_lint//lint/private:machine_report_testing.bzl", "machine_report_rule") +load("//tools/lint:linters.bzl", "tflint") + +machine_tflint_report = machine_report_rule(tflint) diff --git a/examples/terraform/tools/lint/BUILD b/examples/terraform/tools/lint/BUILD new file mode 100644 index 000000000..113a7ffde --- /dev/null +++ b/examples/terraform/tools/lint/BUILD @@ -0,0 +1,6 @@ +"""Linter binary definitions for Terraform. + +tflint is provided via rules_multitool; no additional BUILD targets needed here. +""" + +package(default_visibility = ["//:__subpackages__"]) diff --git a/examples/terraform/tools/lint/linters.bzl b/examples/terraform/tools/lint/linters.bzl new file mode 100644 index 000000000..7ab940996 --- /dev/null +++ b/examples/terraform/tools/lint/linters.bzl @@ -0,0 +1,14 @@ +"Define Terraform linter aspects" + +load("@aspect_rules_lint//lint:lint_test.bzl", "lint_test") +load("@aspect_rules_lint//lint:tflint.bzl", "lint_tflint_aspect") + +tflint = lint_tflint_aspect( + binary = "@aspect_rules_lint//lint:tflint_bin", + config = Label("//:.tflint.hcl"), + plugins = [ + Label("@tflint_plugin_google//:plugin"), + ], +) + +tflint_test = lint_test(aspect = tflint) diff --git a/lint/BUILD.bazel b/lint/BUILD.bazel index b73955c0c..73e6294cd 100644 --- a/lint/BUILD.bazel +++ b/lint/BUILD.bazel @@ -19,6 +19,11 @@ alias( actual = "@multitool//tools/shellcheck", ) +alias( + name = "tflint_bin", + actual = "@multitool//tools/tflint", +) + alias( name = "ty_bin", actual = "@multitool//tools/ty", @@ -274,6 +279,17 @@ bzl_library( ], ) +bzl_library( + name = "tflint", + srcs = ["tflint.bzl"], + deps = ["//lint/private:lint_aspect"], +) + +bzl_library( + name = "tflint_plugins", + srcs = ["tflint_plugins.bzl"], +) + bzl_library( name = "yamllint", srcs = ["yamllint.bzl"], diff --git a/lint/multitool.lock.json b/lint/multitool.lock.json index 22a8d201b..9fe1dc0b5 100644 --- a/lint/multitool.lock.json +++ b/lint/multitool.lock.json @@ -80,6 +80,42 @@ } ] }, + "tflint": { + "binaries": [ + { + "kind": "archive", + "url": "https://github.com/terraform-linters/tflint/releases/download/v0.61.0/tflint_linux_arm64.zip", + "file": "tflint", + "sha256": "999c25cfdb5208fe1133dec6b219e666a39fc2a7a0786a781dc9924ea5945ebf", + "os": "linux", + "cpu": "arm64" + }, + { + "kind": "archive", + "url": "https://github.com/terraform-linters/tflint/releases/download/v0.61.0/tflint_linux_amd64.zip", + "file": "tflint", + "sha256": "ca4e4e8cb7cc3436f2b6979e9c4fd4e2623a66fcca1ad1fe12f8669967636ae2", + "os": "linux", + "cpu": "x86_64" + }, + { + "kind": "archive", + "url": "https://github.com/terraform-linters/tflint/releases/download/v0.61.0/tflint_darwin_arm64.zip", + "file": "tflint", + "sha256": "6593fa24cb6e14d2d0cf7af7fd02a271242f1038af6ecb5384f6738e105a0fea", + "os": "macos", + "cpu": "arm64" + }, + { + "kind": "archive", + "url": "https://github.com/terraform-linters/tflint/releases/download/v0.61.0/tflint_darwin_amd64.zip", + "file": "tflint", + "sha256": "9cd3106c7b74f83cbcd90e0593dfaa1ce14dc5260fe32d946915fd0004aec2f4", + "os": "macos", + "cpu": "x86_64" + } + ] + }, "ty": { "binaries": [ { diff --git a/lint/tflint.bzl b/lint/tflint.bzl new file mode 100644 index 000000000..7bd89a928 --- /dev/null +++ b/lint/tflint.bzl @@ -0,0 +1,284 @@ +"""Configures [tflint](https://github.com/terraform-linters/tflint) to run as a Bazel aspect. + +tflint is a pluggable Terraform linter that checks for possible errors, enforces best practices, +and applies naming conventions. + +Typical usage: + +Use the built-in tflint binary at `@aspect_rules_lint//lint:tflint_bin`, or provide your own +executable target (e.g. via [rules_multitool](https://github.com/theoremlp/rules_multitool)). + +Then declare any tflint plugins you need using the `tflint_plugin` repository rule. In your `MODULE.bazel`: + +```starlark +tflint_plugins = use_extension("@aspect_rules_lint//lint:tflint_plugins.bzl", "tflint_ext") +tflint_plugins.plugin( + name = "tflint_plugin_google", + ruleset_name = "google", + sha256s = { + "linux_amd64": "...", + "darwin_arm64": "...", + }, + url_template = "https://github.com/terraform-linters/tflint-ruleset-google/releases/download/v0.39.0/tflint-ruleset-google_{platform}.zip", +) +use_repo(tflint_plugins, "tflint_plugin_google") +``` + +Then declare the linter aspect, typically in `tools/lint/linters.bzl`: + +```starlark +load("@aspect_rules_lint//lint:tflint.bzl", "lint_tflint_aspect") + +tflint = lint_tflint_aspect( + binary = "@aspect_rules_lint//lint:tflint_bin", + config = Label("//:.tflint.hcl"), + plugins = [ + Label("@tflint_plugin_google//:plugin"), + ], +) +``` + +tflint operates on directories rather than individual files, so the aspect stages all `.tf` sources +from a target into a scratch directory before running the linter. + +### Rule kinds + +By default the aspect visits `tf_module` and `tf_environment` rule kinds. Use the `rule_kinds` +parameter to match your Terraform Bazel rules, and/or tag a `filegroup` with `lint-with-tflint` +to opt it in for linting. +""" + +load("//lint/private:lint_aspect.bzl", "LintOptionsInfo", "filter_srcs", "noop_lint_action", "output_files", "should_visit") + +_MNEMONIC = "AspectRulesLintTFLint" + +def tflint_action(ctx, executable, srcs, stdout, exit_code = None, config = None, plugins = [], policies = [], format = "compact", options = []): + """Run tflint as an action under Bazel. + + Because tflint operates on a directory rather than individual files, this action stages all + source files into a scratch directory and points tflint at it. + + Args: + ctx: Bazel Rule or Aspect evaluation context + executable: File representing the tflint program + srcs: Terraform (.tf) files to lint + stdout: output file for tflint stdout + exit_code: optional output file for exit code. If absent, non-zero exits fail the build. + config: optional .tflint.hcl configuration file + plugins: list of pre-fetched tflint plugin Files to symlink into the plugin directory + policies: list of OPA policy Files (.rego) to stage alongside the Terraform sources + format: tflint output format (e.g. "compact", "json") + options: additional command-line options + """ + inputs = list(srcs) + list(policies) + list(plugins) + + staging_dir = "{}.{}.staging".format(ctx.label.name, _MNEMONIC) + + # Determine the common package prefix for the source files so we can + # rewrite tflint's output paths back to workspace-relative locations. + src_prefix = srcs[0].short_path.rsplit("/", 1)[0] + "/" if srcs and "/" in srcs[0].short_path else "" + + # Symlink .tf sources into the scratch directory (flat — tflint lints one + # module directory at a time). Absolute paths ensure symlinks resolve after + # --chdir into the staging directory. + copy_cmds = ["ln -s $PWD/{src} {dir}/{basename}".format( + src = src.path, + dir = staging_dir, + basename = src.basename, + ) for src in srcs] + + # Symlink OPA policy files preserving workspace-relative paths so + # policy_dir in .tflint.hcl resolves correctly. + policy_cmds = ["mkdir -p {dir}/{dirname} && ln -s $PWD/{src} {dir}/{path}".format( + dir = staging_dir, + dirname = f.short_path.rsplit("/", 1)[0], + src = f.path, + path = f.short_path, + ) for f in policies] + + # Symlink pre-fetched plugins so tflint discovers them without --init + # or network access. + plugin_cmds = ["ln -s $PWD/{src} {dir}/.tflint.d/plugins/{basename}".format( + src = f.path, + dir = staging_dir, + basename = f.basename, + ) for f in plugins] + + config_flag = "" + if config: + # --chdir changes the working directory, so resolve the config path + # relative to the exec root via $PWD before chdir happens. + config_flag = "--config=$PWD/{config}".format(config = config.path) + inputs.append(config) + + extra_flags = " ".join(options) if options else "" + + outputs = [stdout] + if exit_code: + command = """\ +mkdir -p {dir}/.tflint.d/plugins +{copy_cmds} +{policy_cmds} +{plugin_cmds} +export TFLINT_PLUGIN_DIR=$PWD/{dir}/.tflint.d/plugins +{tflint} --chdir={dir} --call-module-type=none {config_flag} --format={format} {extra_flags} >{stdout}_raw 2>&1; rc=$? +sed 's|{sed_bare}|{sed_prefixed}|g' {stdout}_raw > {stdout} +echo $rc > {exit_code} +""".format( + dir = staging_dir, + copy_cmds = "\n".join(copy_cmds), + policy_cmds = "\n".join(policy_cmds), + plugin_cmds = "\n".join(plugin_cmds), + tflint = executable.path, + config_flag = config_flag, + format = format, + extra_flags = extra_flags, + sed_bare = staging_dir + "/", + sed_prefixed = src_prefix, + stdout = stdout.path, + exit_code = exit_code.path, + ) + outputs.append(exit_code) + else: + command = """\ +mkdir -p {dir}/.tflint.d/plugins +{copy_cmds} +{policy_cmds} +{plugin_cmds} +export TFLINT_PLUGIN_DIR=$PWD/{dir}/.tflint.d/plugins +{tflint} --chdir={dir} --call-module-type=none {config_flag} --format={format} {extra_flags} >{stdout}_raw 2>&1; rc=$? +sed 's|{sed_bare}|{sed_prefixed}|g' {stdout}_raw > {stdout} +exit $rc +""".format( + dir = staging_dir, + copy_cmds = "\n".join(copy_cmds), + policy_cmds = "\n".join(policy_cmds), + plugin_cmds = "\n".join(plugin_cmds), + tflint = executable.path, + config_flag = config_flag, + format = format, + extra_flags = extra_flags, + sed_bare = staging_dir + "/", + sed_prefixed = src_prefix, + stdout = stdout.path, + ) + + ctx.actions.run_shell( + inputs = inputs, + outputs = outputs, + tools = [executable], + command = command, + mnemonic = _MNEMONIC, + progress_message = "Linting %{label} with tflint", + ) + +_TF_EXTENSIONS = (".tf",) + +def _tf_files(files): + return [f for f in files if f.path.endswith(_TF_EXTENSIONS)] + +# buildifier: disable=function-docstring +def _tflint_aspect_impl(target, ctx): + if not should_visit(ctx.rule, ctx.attr._rule_kinds, ctx.attr._filegroup_tags): + return [] + + files_to_lint = _tf_files(filter_srcs(ctx.rule)) + outputs, info = output_files(_MNEMONIC, target, ctx) + + if len(files_to_lint) == 0: + noop_lint_action(ctx, outputs) + return [info] + + plugin_files = [] + for plugin_target in ctx.attr._plugins: + plugin_files.extend(plugin_target.files.to_list()) + + common_args = { + "ctx": ctx, + "executable": ctx.executable._tflint, + "srcs": files_to_lint, + "config": ctx.file._config_file, + "plugins": plugin_files, + "policies": ctx.files._policies, + "options": ctx.attr._extra_args, + } + + # Human-readable output (compact format). + tflint_action( + stdout = outputs.human.out, + exit_code = outputs.human.exit_code, + format = "compact", + **common_args + ) + + # Machine-readable output in SARIF format. + # tflint supports --format=sarif natively, so we write directly to the + # final report file — no parse_to_sarif_action conversion needed. + tflint_action( + stdout = outputs.machine.out, + exit_code = outputs.machine.exit_code, + format = "sarif", + **common_args + ) + return [info] + +def lint_tflint_aspect( + binary, + config = None, + plugins = [], + policies = [], + rule_kinds = ["tf_module", "tf_environment"], + filegroup_tags = ["lint-with-tflint"], + extra_args = []): + """A factory function to create a tflint linter aspect. + + Args: + binary: a tflint executable, typically `@aspect_rules_lint//lint:tflint_bin`. + config: optional label for a `.tflint.hcl` configuration file. + plugins: list of labels for pre-fetched tflint plugin binaries + (from `tflint_plugin` repository rules). + policies: list of labels for OPA policy files (`.rego`) used by + the tflint-ruleset-opa plugin. + rule_kinds: rule kinds the aspect should visit. + Defaults to `["tf_module", "tf_environment"]`. + filegroup_tags: tags on filegroup targets that opt them in for linting. + Defaults to `["lint-with-tflint"]`. + extra_args: additional command-line arguments passed to tflint. + + Returns: + An aspect definition that can be used with `--aspects` or in `lint_test`. + """ + return aspect( + implementation = _tflint_aspect_impl, + attrs = { + "_options": attr.label( + default = "//lint:options", + providers = [LintOptionsInfo], + ), + "_tflint": attr.label( + default = binary, + executable = True, + cfg = "exec", + ), + "_config_file": attr.label( + default = config, + allow_single_file = True, + ), + "_plugins": attr.label_list( + default = plugins, + ), + "_policies": attr.label_list( + default = policies, + allow_files = [".rego"], + ), + "_rule_kinds": attr.string_list( + default = rule_kinds, + ), + "_filegroup_tags": attr.string_list( + default = filegroup_tags, + ), + "_extra_args": attr.string_list( + default = extra_args, + ), + }, + ) diff --git a/lint/tflint_plugins.bzl b/lint/tflint_plugins.bzl new file mode 100644 index 000000000..aec787336 --- /dev/null +++ b/lint/tflint_plugins.bzl @@ -0,0 +1,159 @@ +"""Repository rule and module extension for fetching tflint plugin binaries. + +tflint does not have a lockfile mechanism for plugins, so this rule uses Bazel +as the lockfile: each plugin version and its per-platform sha256 digests are +declared in `MODULE.bazel`, and Bazel fetches them hermetically. + +## Usage with bzlmod + +In your `MODULE.bazel`: + +```starlark +tflint_plugins = use_extension("@aspect_rules_lint//lint:tflint_plugins.bzl", "tflint_ext") +tflint_plugins.plugin( + name = "tflint_plugin_google", + ruleset_name = "google", + sha256s = { + "linux_amd64": "abc123...", + "linux_arm64": "def456...", + "darwin_amd64": "789abc...", + "darwin_arm64": "012def...", + }, + url_template = "https://github.com/terraform-linters/tflint-ruleset-google/releases/download/v0.39.0/tflint-ruleset-google_{platform}.zip", +) +use_repo(tflint_plugins, "tflint_plugin_google") +``` + +Then pass the plugin to your aspect definition: + +```starlark +tflint = lint_tflint_aspect( + binary = "@aspect_rules_lint//lint:tflint_bin", + plugins = [Label("@tflint_plugin_google//:plugin")], +) +``` +""" + +_PLATFORMS = { + "linux_amd64": "@platforms//os:linux", + "linux_arm64": "@platforms//os:linux", + "darwin_amd64": "@platforms//os:macos", + "darwin_arm64": "@platforms//os:macos", +} + +_CPU = { + "linux_amd64": "@platforms//cpu:x86_64", + "linux_arm64": "@platforms//cpu:aarch64", + "darwin_amd64": "@platforms//cpu:x86_64", + "darwin_arm64": "@platforms//cpu:aarch64", +} + +def _tflint_plugin_impl(rctx): + name = rctx.attr.ruleset_name + binary_name = "tflint-ruleset-{}".format(name) + + for platform, sha256 in rctx.attr.sha256s.items(): + if platform not in _PLATFORMS: + fail("Unknown platform '{}'. Supported: {}".format(platform, ", ".join(_PLATFORMS.keys()))) + url = rctx.attr.url_template.format(platform = platform) + rctx.download_and_extract( + url = url, + sha256 = sha256, + output = platform, + ) + + # Generate a BUILD file with select() to pick the right platform binary. + lines = [ + 'package(default_visibility = ["//visibility:public"])', + "", + "alias(", + ' name = "plugin",', + " actual = select({", + ] + for platform in rctx.attr.sha256s: + target_name = platform.replace("/", "_") + lines.append(' ":{}_condition": ":{}_bin",'.format(target_name, target_name)) + lines.append(' "//conditions:default": ":unsupported",') + lines.append(" }),") + lines.append(")") + lines.append("") + + # Fallback for unsupported platforms. + lines.append("filegroup(") + lines.append(' name = "unsupported",') + lines.append(" srcs = [],") + lines.append(' tags = ["manual"],') + lines.append(")") + lines.append("") + + for platform in rctx.attr.sha256s: + target_name = platform.replace("/", "_") + os_constraint = _PLATFORMS[platform] + cpu_constraint = _CPU[platform] + + lines.append("config_setting(") + lines.append(' name = "{}_condition",'.format(target_name)) + lines.append(" constraint_values = [") + lines.append(' "{}",'.format(os_constraint)) + lines.append(' "{}",'.format(cpu_constraint)) + lines.append(" ],") + lines.append(")") + lines.append("") + + lines.append("filegroup(") + lines.append(' name = "{}_bin",'.format(target_name)) + lines.append(' srcs = ["{}/{}"],'.format(platform, binary_name)) + lines.append(")") + lines.append("") + + rctx.file("BUILD.bazel", "\n".join(lines)) + +tflint_plugin = repository_rule( + implementation = _tflint_plugin_impl, + doc = """Fetches a tflint plugin for all declared platforms. + +Each platform's binary is downloaded and extracted into its own directory. +A `select()`-based alias named `:plugin` resolves to the correct binary for +the current execution platform. +""", + attrs = { + "ruleset_name": attr.string( + mandatory = True, + doc = "Plugin name, e.g. 'google' for tflint-ruleset-google.", + ), + "url_template": attr.string( + mandatory = True, + doc = "URL template with a `{platform}` placeholder, e.g. " + + "`https://github.com/terraform-linters/tflint-ruleset-google/releases/download/v0.39.0/tflint-ruleset-google_{platform}.zip`.", + ), + "sha256s": attr.string_dict( + mandatory = True, + doc = "Map of platform key (e.g. `linux_amd64`) to sha256 of the archive.", + ), + }, +) + +def _tflint_ext_impl(mctx): + for mod in mctx.modules: + for plugin in mod.tags.plugin: + tflint_plugin( + name = plugin.name, + ruleset_name = plugin.ruleset_name, + url_template = plugin.url_template, + sha256s = plugin.sha256s, + ) + +_plugin_tag = tag_class( + doc = "Declares a tflint plugin to fetch.", + attrs = { + "name": attr.string(mandatory = True, doc = "Repository name for this plugin."), + "ruleset_name": attr.string(mandatory = True, doc = "Plugin name, e.g. 'google'."), + "url_template": attr.string(mandatory = True, doc = "URL template with `{platform}` placeholder."), + "sha256s": attr.string_dict(mandatory = True, doc = "Platform-to-sha256 map."), + }, +) + +tflint_ext = module_extension( + implementation = _tflint_ext_impl, + tag_classes = {"plugin": _plugin_tag}, +)