Warning
This repository is deprecated and archived.
rules_tf_apply has been merged into
rillanetwork/rules_tf and is no
longer maintained here. All of its rules (tf_root_module, tf_init, tf_plan,
tf_apply, tf_destroy, tf_cmd) and tools (list_modules, run) now ship in
rules_tf from the v1.0.0 release onward.
To migrate:
- Drop the
rules_tf_applybazel_dep(and anyarchive_override) fromMODULE.bazel, and pinrules_tftov1.0.0or later. - Replace
load("@rules_tf_apply//tf_apply:defs.bzl", ...)withload("@rules_tf//tf_apply:defs.bzl", ...). - Move tool references from
@rules_tf_apply//tools:...to@rules_tf//tools:....
See the rules_tf README for installation and rules_tf#11 for the full migration notes.
This module provides rules to initialize, plan, and apply Terraform modules using Bazel. This effectively enables using tools like bazel-diff to selectively apply changes to Terraform modules only when necessary.
This depends on the rules_tf module, which provides the necessary
toolchain and providers for Terraform.
# MODULE.bazel
bazel_dep(name = "rules_tf_apply", version = "0.1.0")
bazel_dep(name = "rules_tf", version = "0.0.10")
tf = use_extension("@rules_tf//tf:extensions.bzl", "tf_repositories", dev_dependency = True)
tf.download(
mirror = {
"random": "hashicorp/random:3.3.2",
"null": "hashicorp/null:3.1.1",
"aws": "hashicorp/aws:>=5.0.0",
},
tfdoc_version = "0.19.0",
tflint_version = "0.53.0",
use_tofu = False,
version = "1.12.2",
)
use_repo(tf, "tf_toolchains")
register_toolchains(
"@tf_toolchains//:all",
dev_dependency = True,
)and on the BUILD.bazel file:
# BUILD.bazel
load("@rules_tf_apply//tf_apply:defs.bzl", "tf_module")
tf_module(
name = "my_tf_module",
providers = [
"aws",
],
providers_versions = ":providers",
tags = [
"terraform",
],
deps = [
"//:my_other_module",
],
)Running bazel run //:my_tf_module.init, plan, destroy, or apply would generate 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.
<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 //:my_tf_module.init
bazel run //:my_tf_module.destroy # writes a destroy plan to bazel-tf/<mod>/plan.tfplan
# review the plan output, then:
bazel run //:my_tf_module.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 //:my_tf_module.plan -- --target module.web --target module.api
# Apply with additional flags
bazel run //:my_tf_module.apply -- --target module.database -auto-approve
# Plan with custom options
bazel run //:my_tf_module.plan -- -refresh=false -parallelism=10Any arguments passed after -- are forwarded directly to the underlying Terraform command.
For terraform subcommands without a dedicated target (destroy, state, import, taint, output, refresh, show, validate, ...), tf_root_module exposes a generic <name>.tf target that forwards all arguments to terraform -chdir=<module>:
# Read-only commands
bazel run //:my_tf_module.tf -- validate
bazel run //:my_tf_module.tf -- state list
bazel run //:my_tf_module.tf -- output
# Show a plan that was previously generated
bazel run //:my_tf_module.plan
bazel run //:my_tf_module.tf -- show plan.tfplan
# Import existing infrastructure
bazel run //:my_tf_module.tf -- import aws_iam_role.example example-role
# Destroy (auto-approve is deliberately not implicit — pass it explicitly)
bazel run //:my_tf_module.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_apply//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_apply//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.