diff --git a/MODULE.bazel b/MODULE.bazel index fb5c5731..42debf73 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -26,6 +26,7 @@ bazel_dep(name = "rules_proto", version = "6.0.0") # Needed in the root because we dereference the toolchain in our aspect impl bazel_dep(name = "rules_buf", version = "0.5.2") +bazel_dep(name = "rules_tf", version = "0.0.10") tel = use_extension("@aspect_tools_telemetry//:extension.bzl", "telemetry") use_repo(tel, "aspect_tools_telemetry_report") @@ -41,6 +42,17 @@ use_repo(rules_lint_toolchains, "sarif_parser_toolchains") register_toolchains("@sarif_parser_toolchains//:all") +tf_repositories = use_extension("@rules_tf//tf:extensions.bzl", "tf_repositories") +tf_repositories.download( + mirror = { + "aws": "hashicorp/aws:5.90.0", + }, + version = "1.9.8", +) +use_repo(tf_repositories, "tf_toolchains") + +register_toolchains("@tf_toolchains//:all") + ####### Dev dependencies ######## bazel_dep(name = "bazelrc-preset.bzl", version = "1.1.0", dev_dependency = True) diff --git a/README.md b/README.md index 4d9c46d2..e70588dc 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ Linters which are not language-specific: | Go | [gofmt] or [gofumpt] | | | Gherkin | [prettier-plugin-gherkin] | | | GraphQL | [Prettier] | | -| HCL (Hashicorp Config) | [terraform] fmt | | +| HCL / Terraform | [terraform] fmt | [TFLint] | | HTML | [Prettier] | | | JSON | [Prettier] | | | Java | [google-java-format] | [pmd] , [Checkstyle], [Spotbugs] | @@ -60,12 +60,18 @@ Linters which are not language-specific: | YAML | [yamlfmt] | [yamllint] | | XML | [prettier/plugin-xml] | | +Terraform tooling uses the binaries published by [`rules_tf`](https://github.com/yanndegat/rules_tf) +when bzlmod is enabled. See [docs/formatting.md](./docs/formatting.md#terraform-formatter) and +[docs/linting.md](./docs/linting.md#terraform-linting) for setup details, including guidance for +WORKSPACE users via `rules_lint_setup_tf_toolchains`. + [prettier]: https://prettier.io [google-java-format]: https://github.com/google/google-java-format [flake8]: https://flake8.pycqa.org/en/latest/index.html [pmd]: https://docs.pmd-code.org/latest/index.html [checkstyle]: https://checkstyle.sourceforge.io/cmdline.html [spotbugs]: https://spotbugs.github.io/ +[TFLint]: https://github.com/terraform-linters/tflint [buf lint]: https://buf.build/docs/lint/overview [eslint]: https://eslint.org/ [swiftformat]: https://github.com/nicklockwood/SwiftFormat diff --git a/docs/formatting.md b/docs/formatting.md index a431d696..89f25159 100644 --- a/docs/formatting.md +++ b/docs/formatting.md @@ -48,6 +48,40 @@ format_multirun( File discovery for each language is based on file extension and shebang-based discovery is currently limited to shell. +### Terraform formatter + +When formatting Terraform sources we recommend using the binary published with +[`rules_tf`](https://github.com/yanndegat/rules_tf) so the version is managed by Bazel. +Under bzlmod you can add the module and register the toolchains so the built-in `terraform` +language attribute resolves to the correct executable: + +```starlark +bazel_dep(name = "rules_tf", version = "0.0.10") + +tf = use_extension("@rules_tf//tf:extensions.bzl", "tf_repositories") +tf.download( + version = "1.9.8", + mirror = {"aws": "hashicorp/aws:5.90.0"}, +) +use_repo(tf, "tf_toolchains") +register_toolchains("@tf_toolchains//:all") +``` + +WORKSPACE users can continue to use the multitool-provided binary without any additional setup. +If you need the same version pin in WORKSPACE mode, download `rules_tf` via +`rules_lint_dependencies()` and register the toolchains with the helper provided in this repo: + +```starlark +load("@aspect_rules_lint//lint:tf_toolchains_workspace.bzl", "rules_lint_setup_tf_toolchains") + +rules_lint_setup_tf_toolchains( + version = "1.9.8", + mirror = {"aws": "hashicorp/aws:5.90.0"}, +) +``` + +The helper detects the host platform automatically using Bazel's host platform constraints. + ## Usage ### Configuring formatters diff --git a/docs/linting.md b/docs/linting.md index e900c091..f644739c 100644 --- a/docs/linting.md +++ b/docs/linting.md @@ -107,6 +107,46 @@ Each linter aspect accepts the configuration file(s) as an argument. To specify whether a certain lint rule should be a warning or error, follow the documentation for the linter. rules_lint provides the exit code of the linter process to allow the desired developer experiences listed above. +### Terraform linting + +Terraform modules can be linted with `lint_tflint_aspect`, which relies on the toolchains published by +[`rules_tf`](https://github.com/yanndegat/rules_tf). Add that module (or the equivalent WORKSPACE repositories) +and register the toolchains so the aspect can locate both Terraform and TFLint binaries: + +```starlark +bazel_dep(name = "rules_tf", version = "0.0.10") + +tf = use_extension("@rules_tf//tf:extensions.bzl", "tf_repositories") +tf.download( + version = "1.9.8", + mirror = {"aws": "hashicorp/aws:5.90.0"}, +) +use_repo(tf, "tf_toolchains") +register_toolchains("@tf_toolchains//:all") +``` + +Then declare the aspect alongside your other linters: + +```starlark +load("@aspect_rules_lint//lint:tflint.bzl", "lint_tflint_aspect") + +tflint = lint_tflint_aspect() +``` + +In WORKSPACE projects, fetch `rules_tf` via `rules_lint_dependencies()` and register matching +toolchains using the helper exposed by rules_lint: + +```starlark +load("@aspect_rules_lint//lint:tf_toolchains_workspace.bzl", "rules_lint_setup_tf_toolchains") + +rules_lint_setup_tf_toolchains( + version = "1.9.8", + mirror = {"aws": "hashicorp/aws:5.90.0"}, +) +``` + +The helper detects the host platform automatically using Bazel's host platform constraints. + ## Ignoring targets To ignore a specific target, you can use the `no-lint` tag. This will prevent the linter from visiting the target. diff --git a/example/MODULE.bazel b/example/MODULE.bazel index 85022bc8..6825a3a2 100644 --- a/example/MODULE.bazel +++ b/example/MODULE.bazel @@ -26,6 +26,7 @@ bazel_dep(name = "rules_cc", version = "0.0.9") bazel_dep(name = "gazelle", version = "0.41.0") bazel_dep(name = "rules_shell", version = "0.5.0") bazel_dep(name = "jq.bzl", version = "0.4.0") +bazel_dep(name = "rules_tf", version = "0.0.10") local_path_override( module_name = "aspect_rules_lint", diff --git a/example/README.md b/example/README.md index dc7988ba..2a2cadb2 100644 --- a/example/README.md +++ b/example/README.md @@ -3,6 +3,10 @@ The `src/` folder contains a project that we want to lint. It contains sources in multiple languages. +The `terraform/` directory demonstrates formatting and linting Terraform modules via +[`rules_tf`](https://github.com/yanndegat/rules_tf). The providers and toolchains are configured in +`MODULE.bazel`, so the examples work out-of-the-box with `bazel run` and `bazel build`. + ### With Aspect CLI Run `bazel lint src:all` @@ -33,6 +37,16 @@ From /shared/cache/bazel/user_base/b6913b1339fd4037a680edabc6135c1d/execroot/_ma ``` +Terraform modules can be linted and formatted the same way. For example: + +``` +# Lint the sample module with TFLint +bazel build //terraform/aws_subnet:module --aspects //tools/lint:linters.bzl%tflint --output_groups=rules_lint_human + +# Apply formatting to Terraform sources +bazel run //:format -- terraform/aws_subnet/main.tf +``` + ## ESLint This folder simply follows the instructions at https://typescript-eslint.io/getting-started diff --git a/example/WORKSPACE.bazel b/example/WORKSPACE.bazel index e7ef39c2..c5326f1f 100644 --- a/example/WORKSPACE.bazel +++ b/example/WORKSPACE.bazel @@ -86,6 +86,13 @@ http_archive( url = "https://github.com/bazelbuild/rules_shell/releases/download/v0.5.0/rules_shell-v0.5.0.tar.gz", ) +http_archive( + name = "rules_tf", + sha256 = "64e5f0705d7b434e6942445ea1c3259a7b0ccc70f14827e857662aae0443274e", + strip_prefix = "rules_tf-0.0.10", + url = "https://github.com/yanndegat/rules_tf/archive/refs/tags/v0.0.10.tar.gz", +) + http_archive( name = "bazel_features", sha256 = "06f02b97b6badb3227df2141a4b4622272cdcd2951526f40a888ab5f43897f14", @@ -140,6 +147,13 @@ load("@npm//:repositories.bzl", "npm_repositories") npm_repositories() +load("@aspect_rules_lint//lint:tf_toolchains_workspace.bzl", "rules_lint_setup_tf_toolchains") + +rules_lint_setup_tf_toolchains( + version = "1.9.8", + mirror = {"aws": "hashicorp/aws:5.90.0"}, +) + http_archive( name = "aspect_rules_ts", sha256 = "ee7dcc35faef98f3050df9cf26f2a72ef356cab8ad927efb1c4dc119ac082a19", diff --git a/example/lint.sh b/example/lint.sh index 5deca387..76c30129 100755 --- a/example/lint.sh +++ b/example/lint.sh @@ -37,7 +37,7 @@ if [ $machine == "Windows" ]; then # avoid missing linters on windows platform args=("--aspects=$(echo //tools/lint:linters.bzl%{flake8,pylint,pmd,ruff,vale,yamllint,clang_tidy} | tr ' ' ',')") else - args=("--aspects=$(echo //tools/lint:linters.bzl%{buf,eslint,flake8,keep_sorted,ktlint,pmd,pylint,ruff,shellcheck,stylelint,vale,yamllint,clang_tidy,spotbugs} | tr ' ' ',')") + args=("--aspects=$(echo //tools/lint:linters.bzl%{buf,eslint,flake8,keep_sorted,ktlint,pmd,pylint,ruff,shellcheck,stylelint,tflint,vale,yamllint,clang_tidy,spotbugs} | tr ' ' ',')") fi # NB: perhaps --remote_download_toplevel is needed as well with remote execution? diff --git a/example/terraform/BUILD.bazel b/example/terraform/BUILD.bazel new file mode 100644 index 00000000..8edfe7e6 --- /dev/null +++ b/example/terraform/BUILD.bazel @@ -0,0 +1,11 @@ +load("@rules_tf//tf:def.bzl", "tf_providers_versions") + +package(default_visibility = ["//visibility:public"]) + +tf_providers_versions( + name = "providers", + tf_version = "1.9.8", + providers = { + "aws": "hashicorp/aws:5.90.0", + }, +) diff --git a/example/terraform/aws_subnet/BUILD.bazel b/example/terraform/aws_subnet/BUILD.bazel new file mode 100644 index 00000000..298666e1 --- /dev/null +++ b/example/terraform/aws_subnet/BUILD.bazel @@ -0,0 +1,9 @@ +load("@rules_tf//tf:def.bzl", "tf_module") + +package(default_visibility = ["//visibility:public"]) + +tf_module( + name = "aws_subnet_module", + providers = ["aws"], + providers_versions = "//terraform:providers", +) diff --git a/example/terraform/aws_subnet/main.tf b/example/terraform/aws_subnet/main.tf new file mode 100644 index 00000000..2cba463d --- /dev/null +++ b/example/terraform/aws_subnet/main.tf @@ -0,0 +1,9 @@ +resource "aws_subnet" "example" { + vpc_id = var.vpc_id + cidr_block = "10.0.0.0/24" + availability_zone = "us-east-2a" + + tags={ + Name = "aspect-rules-lint-example" + } +} diff --git a/example/terraform/aws_subnet/outputs.tf b/example/terraform/aws_subnet/outputs.tf new file mode 100644 index 00000000..ea3d884c --- /dev/null +++ b/example/terraform/aws_subnet/outputs.tf @@ -0,0 +1,3 @@ +output "subnet_id" { + value = aws_subnet.example.id +} diff --git a/example/terraform/aws_subnet/variables.tf b/example/terraform/aws_subnet/variables.tf new file mode 100644 index 00000000..38231226 --- /dev/null +++ b/example/terraform/aws_subnet/variables.tf @@ -0,0 +1,3 @@ +variable "vpc_id" { + type = string +} diff --git a/example/terraform/aws_subnet/versions.tf b/example/terraform/aws_subnet/versions.tf new file mode 100644 index 00000000..6947d8e1 --- /dev/null +++ b/example/terraform/aws_subnet/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = "~> 1.9.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "5.90.0" + } + } +} diff --git a/example/test/BUILD.bazel b/example/test/BUILD.bazel index b3936ad5..04fe2a23 100644 --- a/example/test/BUILD.bazel +++ b/example/test/BUILD.bazel @@ -5,7 +5,7 @@ load("@aspect_rules_ts//ts:defs.bzl", "ts_project") load("@bazel_skylib//rules:write_file.bzl", "write_file") load("@rules_python//python:defs.bzl", "py_library") load("@rules_shell//shell:sh_library.bzl", "sh_library") -load("//tools/lint:linters.bzl", "checkstyle_test", "eslint_test", "flake8_test", "pmd_test", "pylint_test", "ruff_test", "shellcheck_test") +load("//tools/lint:linters.bzl", "checkstyle_test", "eslint_test", "flake8_test", "pmd_test", "pylint_test", "ruff_test", "shellcheck_test", "tflint_test") write_file( name = "ts_code_generator", @@ -171,6 +171,13 @@ shellcheck_test( tags = ["manual"], ) +tflint_test( + name = "tflint", + srcs = ["//terraform/aws_subnet:module"], + # Expected to fail based on current content of the module. + tags = ["manual"], +) + format_test( name = "format_test_manual", srcs = [":generated_sh"], diff --git a/example/test/lint_test.bats b/example/test/lint_test.bats index cb7da249..73c25896 100755 --- a/example/test/lint_test.bats +++ b/example/test/lint_test.bats @@ -69,6 +69,22 @@ src/hello.css EOF } +function assert_terraform_lints() { + # TFLint + echo <<"EOF" | assert_output --partial +Notice: `subnet_id` output has no description (terraform_documented_outputs) + + on terraform/aws_subnet/outputs.tf line 1: + 1: output "subnet_id" { +EOF + echo <<"EOF" | assert_output --partial +Notice: `vpc_id` variable has no description (terraform_documented_variables) + + on terraform/aws_subnet/variables.tf line 1: + 1: variable "vpc_id" { +EOF +} + @test "should produce reports" { run $BATS_TEST_DIRNAME/../lint.sh //src:all --no@aspect_rules_lint//lint:color assert_success @@ -116,3 +132,9 @@ EOF # This lint check is disabled in the .eslintrc.cjs file refute_output --partial "Unexpected 'debugger' statement" } + +@test "should report terraform issues from tflint" { + run $BATS_TEST_DIRNAME/../lint.sh //terraform/aws_subnet:module --no@aspect_rules_lint//lint:color + assert_success + assert_terraform_lints +} diff --git a/example/test/machine_outputs/BUILD.bazel b/example/test/machine_outputs/BUILD.bazel index 205f93f1..f58588d3 100644 --- a/example/test/machine_outputs/BUILD.bazel +++ b/example/test/machine_outputs/BUILD.bazel @@ -9,6 +9,7 @@ load( "machine_ruff_report", "machine_shellcheck_report", "machine_stylelint_report", + "machine_tflint_report", "machine_vale_report", "machine_yamllint_report", "report_test", @@ -134,6 +135,18 @@ report_test( report = "machine_buf_report", ) +machine_tflint_report( + name = "machine_tflint_report", + src = "//terraform/aws_subnet:module", +) + +report_test( + name = "tflint_machine_output_test", + expected_tool = "tflint", + expected_uri = "terraform/aws_subnet/variables.tf", + report = "machine_tflint_report", +) + # FIXME: I'm getting a C++ compile failure on macos # machine_clang_tidy_report( # name = "machine_clang_tidy_report", diff --git a/example/test/machine_outputs/machine_output.bzl b/example/test/machine_outputs/machine_output.bzl index dc7cc3a9..f8fe9ca3 100644 --- a/example/test/machine_outputs/machine_output.bzl +++ b/example/test/machine_outputs/machine_output.bzl @@ -2,9 +2,9 @@ load("@bazel_features//:features.bzl", "bazel_features") load("@jq.bzl//jq:jq.bzl", "jq_test") -load("//tools/lint:linters.bzl", "buf", "clang_tidy", "eslint", "flake8", "pylint", "ruff", "shellcheck", "stylelint", "vale", "yamllint") +load("//tools/lint:linters.bzl", "buf", "clang_tidy", "eslint", "flake8", "pylint", "ruff", "shellcheck", "stylelint", "tflint", "vale", "yamllint") -SARIF_TOOL_DRIVER_NAME_FILTER = ".runs[].tool.driver.name" +SARIF_TOOL_DRIVER_NAME_FILTER = ".runs[0].tool.driver.name" PHYSICAL_ARTIFACT_LOCATION_URI_FILTER = ".runs[].results | map(.locations | map(.physicalLocation.artifactLocation.uri)) | flatten | unique[]" def report_test(name, report, expected_tool, expected_uri): @@ -79,3 +79,8 @@ machine_yamllint_report = rule( implementation = _machine_report, attrs = {"src": attr.label(aspects = [yamllint])}, ) + +machine_tflint_report = rule( + implementation = _machine_report, + attrs = {"src": attr.label(aspects = [tflint])}, +) diff --git a/example/tools/lint/linters.bzl b/example/tools/lint/linters.bzl index 8620c1f8..a19b10f7 100644 --- a/example/tools/lint/linters.bzl +++ b/example/tools/lint/linters.bzl @@ -14,6 +14,7 @@ load("@aspect_rules_lint//lint:pylint.bzl", "lint_pylint_aspect") load("@aspect_rules_lint//lint:shellcheck.bzl", "lint_shellcheck_aspect") load("@aspect_rules_lint//lint:spotbugs.bzl", "lint_spotbugs_aspect") load("@aspect_rules_lint//lint:stylelint.bzl", "lint_stylelint_aspect") +load("@aspect_rules_lint//lint:tflint.bzl", "lint_tflint_aspect") load("@aspect_rules_lint//lint:vale.bzl", "lint_vale_aspect") load("@aspect_rules_lint//lint:yamllint.bzl", "lint_yamllint_aspect") @@ -107,6 +108,10 @@ ktlint = lint_ktlint_aspect( ktlint_test = lint_test(aspect = ktlint) +tflint = lint_tflint_aspect() + +tflint_test = lint_test(aspect = tflint) + clang_tidy = lint_clang_tidy_aspect( binary = Label("//tools/lint:clang_tidy"), configs = [ diff --git a/format/BUILD.bazel b/format/BUILD.bazel index 9f46c0bc..4693c665 100644 --- a/format/BUILD.bazel +++ b/format/BUILD.bazel @@ -1,18 +1,19 @@ +load("@bazel_features//:features.bzl", "bazel_features") load("@bazel_lib//:bzl_library.bzl", "bzl_library") load("@bazel_lib//lib:utils.bzl", bazel_lib_utils = "utils") -load("//format/private:formatter_binary.bzl", "BUILTIN_TOOL_LABELS") +load("//format/private:formatter_binary.bzl", "BZLMOD_TOOL_OVERRIDES", "BUILTIN_TOOL_LABELS") package(default_visibility = ["//visibility:public"]) exports_files(glob(["*.bzl"])) -# Alias the multitools for strict repo visibility under bzlmod [ alias( - name = v.split("/")[-1], - actual = v, + name = actual_label.rsplit(":", 1)[-1] if ":" in actual_label else actual_label.rsplit("/", 1)[-1], + actual = actual_label, ) - for v in BUILTIN_TOOL_LABELS.values() + for k, v in BUILTIN_TOOL_LABELS.items() + for actual_label in [BZLMOD_TOOL_OVERRIDES[k] if bazel_features.external_deps.is_bzlmod_enabled and k in BZLMOD_TOOL_OVERRIDES else v] ] bzl_library( diff --git a/format/private/formatter_binary.bzl b/format/private/formatter_binary.bzl index 8563c753..7f090f8c 100644 --- a/format/private/formatter_binary.bzl +++ b/format/private/formatter_binary.bzl @@ -43,6 +43,15 @@ BUILTIN_TOOL_LABELS = { "Python": "@multitool//tools/ruff", } +# Under bzlmod we prefer to use toolchain-provided binaries (which live in @tf_toolchains) +# while keeping WORKSPACE users on the multitool defaults above. Only Terraform needs this +# swap today, but keeping the overrides in a small dict means we can add more languages +# without threading additional conditionals through the alias comprehension. If we never +# need another override, this could be simplified by inlining the conditional at the call site. +BZLMOD_TOOL_OVERRIDES = { + "Terraform": "@tf_toolchains//:terraform", +} + # Flags to pass each tool's CLI when running in check mode CHECK_FLAGS = { "buildifier": "-mode=check", diff --git a/format/repositories.bzl b/format/repositories.bzl index 7e15f8bb..42c51170 100644 --- a/format/repositories.bzl +++ b/format/repositories.bzl @@ -38,6 +38,13 @@ def rules_lint_dependencies(): url = "https://github.com/bazel-contrib/bazel_features/releases/download/v1.9.0/bazel_features-v1.9.0.tar.gz", ) + http_archive( + name = "rules_tf", + sha256 = "64e5f0705d7b434e6942445ea1c3259a7b0ccc70f14827e857662aae0443274e", + strip_prefix = "rules_tf-0.0.10", + url = "https://github.com/yanndegat/rules_tf/archive/refs/tags/v0.0.10.tar.gz", + ) + def fetch_java_format(): http_jar( name = "google-java-format", diff --git a/lint/BUILD.bazel b/lint/BUILD.bazel index bcb82731..ce132cf0 100644 --- a/lint/BUILD.bazel +++ b/lint/BUILD.bazel @@ -1,3 +1,4 @@ +load("@bazel_features//:features.bzl", "bazel_features") load("@bazel_lib//:bzl_library.bzl", "bzl_library") load("@bazel_lib//lib:utils.bzl", bazel_lib_utils = "utils") load("@bazel_skylib//rules:common_settings.bzl", "bool_flag") @@ -199,6 +200,19 @@ bzl_library( ], ) +bzl_library( + name = "tflint", + srcs = ["tflint.bzl"], + deps = [ + "//lint/private:lint_aspect", + ], +) + +bzl_library( + name = "tf_toolchains_workspace", + srcs = ["tf_toolchains_workspace.bzl"], +) + bzl_library( name = "ruff", srcs = ["ruff.bzl"], diff --git a/lint/tf_toolchains_workspace.bzl b/lint/tf_toolchains_workspace.bzl new file mode 100644 index 00000000..177e7849 --- /dev/null +++ b/lint/tf_toolchains_workspace.bzl @@ -0,0 +1,113 @@ +"Utilities to register Terraform/TFLint toolchains when using WORKSPACE mode." + +load("@rules_tf//tf/toolchains:toolchains.bzl", "tf_toolchains") +load("@rules_tf//tf/toolchains/terraform:toolchain.bzl", "terraform_download") +load("@rules_tf//tf/toolchains/tflint:toolchain.bzl", "tflint_download") +load("@rules_tf//tf/toolchains/tfdoc:toolchain.bzl", "tfdoc_download") +load("@rules_tf//tf/toolchains/tofu:toolchain.bzl", "tofu_download") +load("@rules_tf//tf:versions.bzl", "TFDOC_VERSION", "TFLINT_VERSION") +load("@host_platform//:constraints.bzl", "HOST_CONSTRAINTS") + +_DEFAULT_MIRROR = {"aws": "hashicorp/aws:5.90.0"} + +def rules_lint_setup_tf_toolchains( + version = None, + mirror = None, + tflint_version = None, + tfdoc_version = None, + use_tofu = False, + repo_prefix = "rules_lint"): + """Register Terraform/TFLint toolchains in WORKSPACE installs. + + This helper mirrors the `tf_repositories.download` module extension from `rules_tf` + so that WORKSPACE users can obtain the same binaries. The host OS/arch is detected + automatically using Bazel's host platform constraints. + + Args: + version: Terraform (or OpenTofu when `use_tofu=True`) version to download. + mirror: Map of Terraform providers to mirror alongside the toolchain. + tflint_version: Version of TFLint to download. Defaults to rules_tf setting. + tfdoc_version: Version of terraform-docs to download. Defaults to rules_tf setting. + use_tofu: If True, downloads OpenTofu instead of Terraform. + repo_prefix: Prefix used for the intermediate repositories created by this helper. + """ + if version == None: + fail("rules_lint_setup_tf_toolchains requires the 'version' argument to be set") + + host_os = _detect_host_os() + host_arch = _detect_host_arch() + + mirror = mirror or _DEFAULT_MIRROR + tflint_version = tflint_version or TFLINT_VERSION + tfdoc_version = tfdoc_version or TFDOC_VERSION + + terraform_repo = "{}_tf_{}_{}".format(repo_prefix, host_os, host_arch) + terraform_download( + name = terraform_repo, + version = version, + os = host_os, + arch = host_arch, + mirror = mirror, + ) + + tfdoc_repo = "{}_tfdoc_{}_{}".format(repo_prefix, host_os, host_arch) + tfdoc_download( + name = tfdoc_repo, + version = tfdoc_version, + os = host_os, + arch = host_arch, + ) + + tflint_repo = "{}_tflint_{}_{}".format(repo_prefix, host_os, host_arch) + tflint_download( + name = tflint_repo, + version = tflint_version, + os = host_os, + arch = host_arch, + ) + + tofu_repos = [] + if use_tofu: + tofu_repo = "{}_tofu_{}_{}".format(repo_prefix, host_os, host_arch) + tofu_download( + name = tofu_repo, + version = version, + os = host_os, + arch = host_arch, + mirror = mirror, + ) + tofu_repos.append(tofu_repo) + + tf_toolchains( + name = "tf_toolchains", + tflint_repos = [tflint_repo], + tfdoc_repos = [tfdoc_repo], + terraform_repos = [] if use_tofu else [terraform_repo], + tofu_repos = tofu_repos, + os = host_os, + arch = host_arch, + ) + + native.register_toolchains("@tf_toolchains//:all") + +def _detect_host_os(): + if "@platforms//os:linux" in HOST_CONSTRAINTS: + return "linux" + if "@platforms//os:osx" in HOST_CONSTRAINTS: + return "darwin" + if "@platforms//os:windows" in HOST_CONSTRAINTS: + return "windows" + fail("Unsupported host OS constraints: {}".format(HOST_CONSTRAINTS)) + +def _detect_host_arch(): + if ( + "@platforms//cpu:aarch64" in HOST_CONSTRAINTS or + "@platforms//cpu:arm64" in HOST_CONSTRAINTS + ): + return "arm64" + if ( + "@platforms//cpu:x86_64" in HOST_CONSTRAINTS or + "@platforms//cpu:amd64" in HOST_CONSTRAINTS + ): + return "amd64" + fail("Unsupported host architecture constraints: {}".format(HOST_CONSTRAINTS)) diff --git a/lint/tflint.bzl b/lint/tflint.bzl new file mode 100644 index 00000000..25da5db9 --- /dev/null +++ b/lint/tflint.bzl @@ -0,0 +1,187 @@ +"""Configures [TFLint](https://github.com/terraform-linters/tflint) to run as a Bazel aspect. + +Typical usage: + +```starlark +load("@aspect_rules_lint//lint:tflint.bzl", "lint_tflint_aspect") +load("@aspect_rules_lint//lint:lint_test.bzl", "lint_test") + +tflint = lint_tflint_aspect() +tflint_test = lint_test(aspect = tflint) +``` + +Because the aspect relies on toolchains provided by [`rules_tf`](https://github.com/yanndegat/rules_tf), +make sure your `MODULE.bazel` (or WORKSPACE equivalent) registers them: + +```starlark +bazel_dep(name = "rules_tf", version = "0.0.10") + +tf = use_extension("@rules_tf//tf:extensions.bzl", "tf_repositories") +tf.download( + version = "1.9.8", + mirror = {"aws": "hashicorp/aws:5.90.0"}, +) +use_repo(tf, "tf_toolchains") +register_toolchains("@tf_toolchains//:all") +``` + +By default the aspect runs with the opinionated config bundled with `rules_tf`. To supply your own +configuration or forward CLI flags, pass the optional parameters: + +```starlark +tflint = lint_tflint_aspect( + config = "//terraform:custom_tflint.hcl", + extra_args = ["--ignore-module=github.com/example"], +) +``` + +Attach the aspect to any rules that provide `TfModuleInfo`; the helper above is also compatible +with `lint_test` so Terraform violations can fail CI. +""" + +load("@rules_tf//tf/rules:providers.bzl", "TfModuleInfo") +load("//lint/private:lint_aspect.bzl", "LintOptionsInfo", "OPTIONAL_SARIF_PARSER_TOOLCHAIN", "OUTFILE_FORMAT", "filter_srcs", "noop_lint_action", "output_files", "should_visit") + +_MNEMONIC = "AspectRulesLintTflint" + +_TERRAFORM_EXTENSIONS = (".tf", ".tf.json") + +def _terraform_files(files): + return [f for f in files if f.basename.endswith(_TERRAFORM_EXTENSIONS)] + +def _tflint_action( + ctx, + module, + tflint_runtime, + tf_runtime, + stdout, + exit_code = None, + extra_args = []): + """Run tflint under Bazel.""" + inputs = module.transitive_srcs.to_list() + tflint_runtime.deps + tf_runtime.deps + + # The wrapper accepts an optional custom config file argument. + config = "" + if ctx.files._config: + config = ctx.files._config[0].short_path + inputs.append(ctx.files._config[0]) + + args = ctx.actions.args() + args.add(tf_runtime.tf.path) + args.add(tflint_runtime.runner.path) + args.add(tf_runtime.mirror.path) + args.add(module.module_path) + args.add(config) + args.add_all(extra_args) + + outputs = [stdout] + if exit_code: + outputs.append(exit_code) + + # We intentionally avoid `set -e` when recording the exit code so failures still emit output. + command = """\ +TF_BIN="$1"; RUNNER="$2"; MIRROR="$3"; MODULE="$4"; CONFIG="$5"; shift 5 +TF_DIR="$(dirname "$TF_BIN")" +export PATH="$TF_DIR:$PATH" +"$TF_BIN" -chdir="$PWD/$MODULE" init -backend=false -input=false -plugin-dir="$PWD/$MIRROR" >/dev/null +""" + + if exit_code: + command += """\ +STATUS=0 +"$RUNNER" "$MODULE" "$CONFIG" "$@" >"{stdout}" || STATUS=$? +echo "$STATUS" > "{exit_code}" +exit 0 +""".format(stdout = stdout.path, exit_code = exit_code.path) + else: + command = "set -euo pipefail\n" + command + command += "\"$RUNNER\" \"$MODULE\" \"$CONFIG\" \"$@\" >\"{stdout}\"\n".format(stdout = stdout.path) + + ctx.actions.run_shell( + mnemonic = _MNEMONIC, + inputs = inputs, + tools = [tf_runtime.tf, tflint_runtime.runner], + outputs = outputs, + command = command, + arguments = [args], + progress_message = "Linting %{label} with TFLint", + ) + +# 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 [] + + if TfModuleInfo not in target: + fail("Target {} does not provide TfModuleInfo required by lint_tflint_aspect.".format(target.label)) + + module = target[TfModuleInfo] + terraform_files = _terraform_files(filter_srcs(ctx.rule)) + outputs, info = output_files(_MNEMONIC, target, ctx) + + if len(terraform_files) == 0: + noop_lint_action(ctx, outputs) + return [info] + + color_args = ["--color"] if ctx.attr._options[LintOptionsInfo].color else ["--no-color"] + common_args = color_args + ["--call-module-type=none"] + ctx.attr._extra_args + + tflint_runtime = ctx.toolchains["@rules_tf//:tflint_toolchain_type"].runtime + tf_runtime = ctx.toolchains["@rules_tf//:tf_toolchain_type"].runtime + + # Human-readable output + _tflint_action( + ctx = ctx, + module = module, + tflint_runtime = tflint_runtime, + tf_runtime = tf_runtime, + stdout = outputs.human.out, + exit_code = outputs.human.exit_code, + extra_args = common_args, + ) + + # Machine readable JSON output that we convert to SARIF. + raw_machine_report = ctx.actions.declare_file(OUTFILE_FORMAT.format(label = target.label.name, mnemonic = _MNEMONIC, suffix = "raw_machine_report")) + _tflint_action( + ctx = ctx, + module = module, + tflint_runtime = tflint_runtime, + tf_runtime = tf_runtime, + stdout = raw_machine_report, + exit_code = outputs.machine.exit_code, + extra_args = common_args + ["--format", "sarif"], + ) + ctx.actions.symlink( + output = outputs.machine.out, + target_file = raw_machine_report, + ) + return [info] + +def lint_tflint_aspect( + config = None, + extra_args = [], + rule_kinds = ["tf_module"], + filegroup_tags = ["lint-with-tflint"]): + """Create an aspect that runs TFLint on Terraform modules defined by rules_tf.""" + return aspect( + implementation = _tflint_aspect_impl, + attr_aspects = ["deps"], + attrs = { + "_options": attr.label( + default = "//lint:options", + providers = [LintOptionsInfo], + ), + "_config": attr.label_list( + allow_files = True, + default = [config] if config else [], + ), + "_extra_args": attr.string_list(default = extra_args), + "_rule_kinds": attr.string_list(default = rule_kinds), + "_filegroup_tags": attr.string_list(default = filegroup_tags), + }, + toolchains = [ + "@rules_tf//:tf_toolchain_type", + "@rules_tf//:tflint_toolchain_type", + OPTIONAL_SARIF_PARSER_TOOLCHAIN, + ], + )