Note: This is a significant fork of yanndegat/rules_tf with additions including multi-version provider mirrors,
mirror_jsonsupport, inline provider declarations, and rules to init/plan/apply root modules (absorbed from the formerrules_tf_applymodule). Requires Bazel 8 or later.
The Tf rules are useful to validate, lint, format, plan and apply terraform code.
They can typically be used in a terraform monorepo of modules to lint, run validation tests, auto generate documentation and enforce the consistency of Tf and providers versions across all modules.
Because now you can either use "tofu" or "terraform" binary.
To import rules_tf in your project, you first need to add it to your MODULE.bazel file:
bazel_dep(name = "rules_tf", version = "1.0.0")
git_override(
module_name = "rules_tf",
remote = "https://github.com/rillanetwork/rules_tf",
tag = "v1.0.0",
# Or pin to an exact commit for immutability:
# commit = "...",
)
tf = use_extension("@rules_tf//tf:extensions.bzl", "tf_repositories", dev_dependency = True)
tf.download(
version = "1.9.5",
tflint_version = "0.53.0",
tfdoc_version = "0.19.0",
use_tofu = False,
mirror = [
"hashicorp/random:3.3.2",
"hashicorp/null:3.1.1",
# The same provider may appear multiple times with different versions.
# Modules then pick whichever version they require via their own
# `tf_providers_versions` target / required_providers block.
# "hashicorp/random:3.6.0",
]
)
# Alternatively, load the provider list from a JSON file:
# tf.download(
# version = "1.9.5",
# mirror_json = "//terraform:providers.json",
# )
# Switch to tofu
# tf = use_extension("@rules_tf//tf:extensions.bzl", "tf_repositories")
# tf.download(
# version = "1.6.0",
# use_tofu = True,
# mirror = [
# "hashicorp/random:3.3.2",
# "hashicorp/null:3.1.1",
# ]
# )
use_repo(tf, "tf_toolchains")
register_toolchains(
"@tf_toolchains//:all",
dev_dependency = True,
)Instead of listing providers inline in MODULE.bazel, you can maintain them in a
standalone JSON file and reference it with mirror_json:
// terraform/providers.json
[
"hashicorp/random:3.3.2",
"hashicorp/null:3.1.1"
]tf.download(
version = "1.9.5",
mirror_json = "//terraform:providers.json",
)The JSON file must contain an array of strings in the same
"[hostname/]namespace/type:version" format used by the inline mirror attribute.
This is useful when the provider list is generated or shared across multiple
repositories.
Once you've imported the rule set, you can then load the tf rules in your BUILD files with:
load("@rules_tf//tf:def.bzl", "tf_module")
tf_module(
name = "root-mod-a",
providers = {
"random": "hashicorp/random:3.3.2",
"null": "hashicorp/null:3.1.1",
},
tf_version = ">= 1.9",
deps = [
"//tf/modules/mod-a",
],
)Each entry in the providers dict maps a local alias to a "source:version" string.
The same provider source can appear in different modules at different versions -
as long as every version is listed in the mirror of your tf.download() tag.
For providers that need multiple configurations (e.g. multi-region), use the
dict form with configuration_aliases:
tf_module(
name = "multi-provider-module",
providers = {
"random": {
"source": "hashicorp/random",
"version": "3.3.2",
"configuration_aliases": ["random.primary", "random.secondary"],
},
"aws": {
"source": "hashicorp/aws",
"version": "5.0.0",
"configuration_aliases": ["aws.us_east_1", "aws.us_west_2"],
},
},
tf_version = ">= 1.9",
)Modules that use provider configuration aliases are designed to be nested (called by other modules) and cannot be validated standalone because they don't have concrete provider configurations. For these modules, use skip_validation = True:
# Nested module with provider aliases - cannot validate standalone
tf_module(
name = "multi-region-module",
providers = {
"aws": {
"source": "hashicorp/aws",
"version": "5.0.0",
"configuration_aliases": ["aws.us_east_1", "aws.us_west_2"],
},
},
tf_version = ">= 1.9",
skip_validation = True,
)
# Root module that uses the nested module - can validate
tf_module(
name = "root-module",
providers = {
"aws": "hashicorp/aws:5.0.0",
},
tf_version = ">= 1.9",
deps = ["//tf/modules/multi-region-module"],
)This is necessary because Terraform cannot validate a module that declares configuration aliases without having concrete provider configurations passed to it from a parent module.
To ensure a consistent binary version across the team, you can create an alias to the prebuilt binaries:
# Likewise for tofu, tfdoc, and tflint.
alias(
name = "terraform",
actual = "@tf_toolchains//:terraform",
)And you can use bazel run //:terraform which uses the same version as configured in your MODULE.bazel.
- Using custom tflint config file
load("@rules_tf//tf:def.bzl", "tf_module")
filegroup(
name = "tflint-custom-config",
srcs = [
"my-tflint-config.hcl",
],
)
tf_module(
name = "mod-a",
providers = {
"random": "hashicorp/random:3.3.2",
},
tf_version = ">= 1.9",
tflint_config = ":tflint-custom-config",
)- Generating versions.tf.json files
Terraform linter by default requires that all providers used by a module are versioned. It is possible to generate a versions.tf.json file by running a dedicated target:
load("@rules_tf//tf:def.bzl", "tf_module")
tf_module(
name = "root-mod-a",
providers = {
"random": "hashicorp/random:3.3.2",
},
tf_version = ">= 1.9",
deps = [
"//tf/modules/mod-a",
],
)bazel run //path/to/root-mod-a:gen-tf-versionsor generate all files of a workspace:
bazel cquery 'kind(tf_gen_versions, //...)' --output files | xargs -n1 bash- Generating terraform doc files
It is possible to generate a README.md file by running a dedicated target for terraform modules:
load("@rules_tf//tf:def.bzl", "tf_gen_doc")
tf_gen_doc(
name = "tfgendoc",
modules = ["//{}/{}".format(package_name(), m) for m in subpackages(include = ["**/*.tf"])],
)and run the following command to generate docs for all sub packages.
bazel run //path/to:tfgendocIt is also possible to customize terraform docs config:
load("@rules_tf//tf:def.bzl", "tf_gen_doc")
filegroup(
name = "tfdoc-config",
srcs = [
"my-tfdoc-config.yaml",
],
)
tf_gen_doc(
name = "custom-tfgendoc",
modules = ["//{}/{}".format(package_name(), m) for m in subpackages(include = ["**/*.tf"])],
config = ":tfdoc-config",
)- Formatting terraform files
It is possible to format terraform files by running a dedicated target:
load("@rules_tf//tf:def.bzl", "tf_format")
tf_format(
name = "tffmt",
modules = ["//{}/{}".format(package_name(), m) for m in subpackages(include = ["**/*.tf"])],
)and run the following command to generate docs for all sub packages.
bazel run //path/to:tffmtThe tf_root_module macro (absorbed from the former rules_tf_apply module) provides rules to initialize, plan, and apply Terraform root modules using Bazel. This effectively enables using tools like bazel-diff to selectively apply changes to Terraform modules only when necessary.
Pair it with a tf_module in the same BUILD.bazel file:
load("@rules_tf//tf:def.bzl", "tf_module")
load("@rules_tf//tf_apply:defs.bzl", "tf_root_module")
tf_module(
name = "vpc",
providers = {
"aws": "hashicorp/aws:6.43.0",
},
tf_version = "1.12.2",
)
tf_root_module(
name = "vpc",
module = ":vpc",
backend = {
"s3": {
"bucket": "my-terraform-state",
"key": "vpc.tfstate",
"region": "us-west-2",
},
},
tfvars = {
"environment": "staging",
"azs": ["us-west-2a", "us-west-2b"],
"enable_flow_logs": True,
"max_nat_gateways": 3,
"tags": {"team": "platform"},
},
tags = ["module:vpc"],
)tfvars accepts arbitrary typed Starlark values - strings, numbers, bools, lists, and nested maps. The whole dict is JSON-encoded and materialized as a *.auto.tfvars.json file, which Terraform loads natively with full typing, so a list(string) or bool variable receives a correctly-typed value rather than a quoted string.
This synthesizes run targets for each phase of the terraform lifecycle:
bazel run //path/to:vpc.init
bazel run //path/to:vpc.plan
bazel run //path/to:vpc.apply # applies the plan written by .plan
bazel run //path/to:vpc.destroy # plans a destroy; .apply consumes it
bazel run //path/to:vpc.tf -- <subcommand>Running these generates a bazel-tf directory at the root of the workspace that must be gitignored. This directory contains the Terraform state and plan files generated by each of the phases.
backend takes a single-key dict of {type: config} and renders a *.bazel.backend.tf.generated file; tfvars (and label-keyed tfvars_deps) render a *.bazel.auto.tfvars.json.generated file. Pass output_json = True to also write plan.tfplan.json next to the binary plan.
<name>.destroy mirrors .plan - it runs terraform plan -destroy and writes the destroy plan to bazel-tf/<module>/plan.tfplan. The subsequent <name>.apply then consumes that plan exactly like a forward apply, preserving the same review-then-execute workflow:
bazel run //path/to:vpc.init
bazel run //path/to:vpc.destroy # writes a destroy plan to bazel-tf/<mod>/plan.tfplan
# review the plan output, then:
bazel run //path/to:vpc.apply # consumes the destroy plan and tears down.destroy is purely a planning step - nothing is actually destroyed until .apply runs against the destroy plan. Note that .destroy overwrites plan.tfplan (same as .plan does), so the last plan written is the one .apply will execute.
You can pass additional Terraform arguments using Bazel's -- syntax:
# Target specific resources
bazel run //path/to:vpc.plan -- --target module.web --target module.api
# Plan with custom options
bazel run //path/to:vpc.plan -- -refresh=false -parallelism=10Any arguments passed after -- are forwarded directly to the underlying Terraform command.
For terraform subcommands without a dedicated target (state, import, taint, output, refresh, show, ...), tf_root_module exposes a generic <name>.tf target that forwards all arguments to terraform -chdir=<module>:
# Read-only commands
bazel run //path/to:vpc.tf -- state list
bazel run //path/to:vpc.tf -- output
# Show a plan that was previously generated
bazel run //path/to:vpc.plan
bazel run //path/to:vpc.tf -- show plan.tfplan
# Import existing infrastructure
bazel run //path/to:vpc.tf -- import aws_iam_role.example example-role
# Destroy (auto-approve is deliberately not implicit - pass it explicitly)
bazel run //path/to:vpc.tf -- destroy -auto-approve -target=null_resource.xUnlike .apply, the .tf target adds no implicit flags (no -auto-approve, no -input=false, no plan file). The caller is responsible for whatever terraform needs. This keeps destructive operations opt-in rather than baked into the target.
Two versioned py_binary tools ship with this ruleset so consuming repos share one implementation of terraform module enumeration and fan-out orchestration. A change to affected-detection or the artifact schema lands here once and every consumer inherits it on the next pin bump.
Enumerates tf_root_module targets (via kind(tf_plan, <query_path>)) and emits a JSON matrix describing each one:
BASE_REF=origin/main bazel run @rules_tf//tools:list_modules -- //terraform/...Each row: {"package", "module_package", "name", "skip", "affected"}.
skipis true when the module'stf_plantarget is taggeddeploy:manual.affectedis true when the module's transitive bazel deps include a file changed betweenBASE_REF(env) andHEAD. WithBASE_REFunset, every module isaffected(the full list).module_packageis where the rendered terraform working dir lives - equal topackageunless the root pointsmodule =at a shared module elsewhere.
This output is cloud-neutral by design: it carries module identity plus the skip/affected classification and nothing tenant-specific. A consumer that keys CI off deployment topology (an account per module, say) decorates these rows with its own fields from its own path convention - the ruleset does not own any tenant's cloud/account layout.
Fans out init/plan/apply over the modules under a query, in-process:
bazel run @rules_tf//tools:run -- //terraform/... init plan \
[--extra_var_file vars.tfvars] [--plan_artifacts_dir out/]With --plan_artifacts_dir (and plan among the actions) it copies each module's plan.tfplan.json to <package>--<name>.json, writes an error envelope for failed plans, and emits a modules.json matrix - best-effort reporting that never fails the run.