Skip to content

iac(tofu): pin providers where OpenTofu actually reads them + commit locks - #1108

Merged
mdheller merged 1 commit into
mainfrom
fix/tofu-provider-pinning
Aug 1, 2026
Merged

iac(tofu): pin providers where OpenTofu actually reads them + commit locks#1108
mdheller merged 1 commit into
mainfrom
fix/tofu-provider-pinning

Conversation

@mdheller

Copy link
Copy Markdown
Member

The gap

infra/tofu/shared/versions.tf pinned google/google-beta at ~> 6.0 and described them as "used only in envs/gcp- and modules that explicitly declare provider"*.

Both halves were false. OpenTofu has no include mechanism for root modules — required_providers is read only from the directory tofu init runs in. infra/tofu/shared/ is a sibling root with no backend and no resources; the envs reference ../../modules/* and never that path. So the file was never loaded by any env, and none of envs/{local,gcp-landing,shared,prod} declared providers of its own.

A version constraint that constrains nothing, documented as constraining something.

Measured with OpenTofu 1.8.3 (the TOFU_VERSION in tofu-plan.yml), clean init, no plugin cache:

root before after
envs/local local/null/random unconstrained ~> 2.5 / ~> 3.2 / ~> 3.6 → 2.9.0 / 3.3.0 / 3.9.0
envs/gcp-landing no required_providers google + google-beta ~> 6.06.50.0
envs/shared google + google-beta 7.42.0 (unconstrained) google + google-beta ~> 6.06.50.0
envs/prod google + google-beta 7.42.0 (unconstrained) google + google-beta ~> 6.06.50.0
shared/ 6 providers installed for zero resources 0 providers

envs/prod — the production root — was resolving a full major above the intent written in shared/versions.tf. And with .terraform.lock.hcl gitignored, every CI run re-resolved from the registry: a green tofu validate guaranteed nothing about the next run.

Strategy: required_providers per env root

Not a shared file. OpenTofu offers no native include, and every "shared" alternative is worse:

  • Symlinked / duplicated versions.tf forces every root to carry the union of providers. A required_providers entry is installed and locked even when nothing references it — verified directly: a config whose only resource is random_password still downloaded and locked google 6.50.0 when google was listed. envs/local is documented as "No cloud provider required"; a shared file would have made the local k3d dev loop depend on the google provider.
  • Symlinks add a Windows-checkout failure mode for zero benefit.

Per-root files also match what environments/*/versions.tf and bootstrap/*/main.tf already do, so this makes envs/* consistent with the rest of the tree rather than introducing a second pattern. And the pins are self-enforcing — OpenTofu fails init if they're violated — so no drift-guard job is needed to give them teeth.

6.x matches environments/gcp-{gke,ephemeral,persistent}, which were already pinned there.

shared/versions.tf keeps required_version and drops required_providers: that root has no resources. The comment now states what the directory is and is not, and points at the files carrying the real pins.

Lock files: stop gitignoring them

Recommendation taken: commit them. The pins bound a range; the lock is what makes resolution reproducible, and it carries registry checksums so init verifies what it downloads.

Tradeoff:

  • Cost — churn on every intentional provider bump (tofu providers lock must be re-run), and PR conflicts when two branches bump the same provider. Multi-arch entries are required or init fails on the platform you forgot.
  • Mitigation — locks generated with OpenTofu 1.8.3 via tofu providers lock -platform=linux_amd64 -platform=darwin_arm64 -platform=darwin_amd64, covering CI and both Mac architectures. They are 2–4 KB each. Re-running init against them produced no diff, so they are stable, not churning.
  • Regeneration command is recorded in infra/tofu/.gitignore next to the un-ignore, so the next person doesn't have to rediscover it.

Locks are committed for the 4 envs/* roots only — the ones CI validates, where the guarantee is load-bearing. environments/* and bootstrap/* are deliberately left unlocked in this PR (CI never touches them, and two of those paths belong to other lanes).

Behaviour change from the major-version move: none observed at validate

The 7.42.0 → 6.50.0 move produced no change in validate output. Every root clean at 7.42.0 is clean at 6.50.0 — no new errors, no new warnings. envs/gcp-landing was checked at both majors explicitly (~> 7.0 and ~> 6.0) and is clean at both.

Unmeasured: tofu plan was not run — it needs GCP credentials. Schema-level compatibility is confirmed; apply-time differences between google 6.x and 7.x (changed defaults, new required fields) are not covered by this evidence.

Also unverified: CI itself. GitHub Actions is at a spend cap (456 queued, 0 running), so no workflow ran for this branch. Everything above is local, with .terraform removed and no plugin cache, replicating the CI command sequence — but it is not a CI result.

Caveat on the gcp-landing measurement

envs/gcp-landing cannot tofu init on main at allmain.tf:215 has invalid HCL (threshold_rules { threshold_percent = 0.8 spend_basis = ... }, a single-line block with two arguments). That is the bug #1103 fixes. Its post-pin numbers above were therefore measured with #1103's gcp-landing hunk applied locally; that hunk is not part of this PR. The lock file is unaffected — it is a function of required_providers, and the resource bodies introduce no additional providers.

Unvalidated-root survey (requested; not fixed here)

tofu-plan.yml validates 4 of 14 configuration roots. Here is what the other 10 do today under OpenTofu 1.8.3, clean init:

root providers resolved init validate note
bootstrap/aws aws 5.100.0 (~> 5.0) ok ok w/ warning aws_s3_bucket_lifecycle_configuration.tofu_state has no filter/prefix"will be an error in a future version of the provider"
bootstrap/azure azurerm 3.117.1 (~> 3.0) ok ok azurerm 3.x while environments/azure-aks pins ~> 4.0
bootstrap/gcp google 5.45.2 (~> 5.0) ok ok google 5.x — third GCP major in the estate
environments/aws-eks aws 5.100.0 + tls/time/null/cloudinit/helm/kubernetes ok ok transitive providers from terraform-aws-modules/{vpc,eks} carry minimum-only constraints (>= 3.0.0 etc.) and still float
environments/azure-aks azurerm 4.81.0 ok ok
environments/gcp-ephemeral google 6.50.0 ok ok
environments/gcp-gke google + google-beta 6.50.0 ok ok
environments/gcp-persistent google 6.50.0 ok ok
environments/ibm-iks FAILS resolves implicit hashicorp/ibm, which does not exist. Fixed by #1103's modules/ibm-trusted-profile required_providers. Untouched here — another lane owns it.
shared/ 0 (was 6) ok ok changed by this PR

Two findings worth their own tickets:

  1. The estate spans three GCP provider majorsbootstrap/gcp at 5.x, environments/gcp-* and now envs/* at 6.x. Deliberate or not, nothing records which.
  2. shared/ and envs/shared share a name and nothing else. CI's step is literally labelled validate — shared and validates envs/shared. Anyone reading the workflow would reasonably conclude infra/tofu/shared/ was covered. It never was. That naming collision is plausibly why the false comment survived.

Also: bootstrap/* set no required_version, unlike every other root.

Recommendation for the tofu-plan.yml lane (#1097 — not edited here)

Widen fmt-and-validate to all 14 roots, or at minimum add bootstrap/* and environments/gcp-*. A matrix over root directories would replace the four hand-written steps and stop new roots from landing unvalidated. environments/ibm-iks will need #1103 first.

Note tofu fmt -check -recursive infra/tofu/ currently fails on main for three files — all owned by #1103 (environments/aws-eks/main.tf, modules/github-oidc-aws/main.tf, modules/ibm-trusted-profile/variables.tf). Everything this PR touches is fmt-clean.

Lane hygiene

Rebased on origin/main @ 55a3d08. git merge-tree against #1103 and #1097 is clean with zero overlapping files. Stayed out of environments/ibm-iks and modules/ibm-trusted-profile entirely.

🤖 Generated with Claude Code

…locks

infra/tofu/shared/versions.tf pinned google/google-beta at ~> 6.0 and said the
pins were "used only in envs/gcp-* and modules that explicitly declare
provider". Neither half was true. OpenTofu has no include mechanism for root
modules: required_providers is read only from the directory `tofu init` runs
in. infra/tofu/shared/ is a sibling root with no backend and no resources; the
envs reference ../../modules/*, never that path. So the file was never loaded
by any env, and none of envs/{local,gcp-landing,shared,prod} declared providers
of its own.

Measured with OpenTofu 1.8.3 (the TOFU_VERSION in tofu-plan.yml), clean init:
envs/prod and envs/shared resolved google + google-beta 7.42.0 — a full major
above the intent written in shared/versions.tf. envs/local floated
local/null/random. With .terraform.lock.hcl gitignored, every CI run
re-resolved from the registry, so a green `tofu validate` guaranteed nothing
about the next run.

Strategy: required_providers per env root, not a shared file. OpenTofu offers
no native include, and the alternatives are worse. A symlinked or duplicated
shared versions.tf forces every root to carry the union of providers, and a
required_providers entry is installed and locked even when nothing references
it (verified) — envs/local, documented as "no cloud provider required", would
have started depending on google. Per-root files also match what
environments/*/versions.tf and bootstrap/*/main.tf already do, so this makes
envs/* consistent with the rest of the tree instead of adding a second pattern.

Each env declares only the providers it uses:
  envs/local        local ~> 2.5, null ~> 3.2, random ~> 3.6
  envs/gcp-landing  google + google-beta ~> 6.0
  envs/shared       google + google-beta ~> 6.0
  envs/prod         google + google-beta ~> 6.0

6.x matches environments/gcp-{gke,ephemeral,persistent}, which were already
pinned there.

shared/versions.tf keeps required_version and drops required_providers: that
root has no resources, and declaring providers made `tofu init` fetch and lock
six of them for a configuration that uses none. The comment now states what the
directory is and is not, and points at the files that carry the real pins.

Stop gitignoring .terraform.lock.hcl. The pins bound a range; the lock is what
makes resolution reproducible, and it carries registry checksums so init
verifies what it downloads. Locks for the four envs were generated with
OpenTofu 1.8.3 via `tofu providers lock` for linux_amd64 (CI), darwin_arm64 and
darwin_amd64, so init works on CI and on either Mac. They are 2-4 KB each and
did not change when init was re-run against them.

Verified locally, OpenTofu 1.8.3, .terraform removed, no plugin cache, lock
committed — the CI sequence:

  envs/local        init ok, validate ok  local 2.9.0 null 3.3.0 random 3.9.0
  envs/gcp-landing  init ok, validate ok  google + google-beta 6.50.0
  envs/shared       init ok, validate ok  google + google-beta 6.50.0
  envs/prod         init ok, validate ok  google + google-beta 6.50.0
  shared/           init ok, validate ok  zero providers (was six)

The 7.42.0 -> 6.50.0 move produced no change in validate output: every root
that was clean at 7.42.0 is clean at 6.50.0, with no new errors or warnings.
envs/gcp-landing was checked at both majors explicitly and is clean at both.
`tofu plan` was not run — it needs GCP credentials — so apply-time differences
between the majors are unmeasured.
Copilot AI review requested due to automatic review settings July 30, 2026 05:30

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Moves provider version constraints from an unused “shared” root into the actual envs/* OpenTofu roots, and enables committing lock files by removing .terraform.lock.hcl from .gitignore to make provider resolution reproducible.

Changes:

  • Remove required_providers from infra/tofu/shared/versions.tf and document why it is not an include point.
  • Add per-root versions.tf files for infra/tofu/envs/{local,gcp-landing,shared,prod} with explicit provider pins.
  • Update infra/tofu/.gitignore to stop ignoring .terraform.lock.hcl and record a lock regeneration command.

Reviewed changes

Copilot reviewed 6 out of 10 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
infra/tofu/shared/versions.tf Drops unused provider pins from the shared/ root and adds explanatory documentation.
infra/tofu/envs/shared/versions.tf Introduces root-level provider pins for envs/shared so tofu init reads them.
infra/tofu/envs/prod/versions.tf Introduces root-level provider pins for envs/prod to prevent provider major drift.
infra/tofu/envs/local/versions.tf Pins only local/dev providers for the cloud-free envs/local root.
infra/tofu/envs/gcp-landing/versions.tf Introduces root-level provider pins for envs/gcp-landing.
infra/tofu/.gitignore Un-ignores lock files and documents how to generate multi-platform locks.
Files not reviewed (4)
  • infra/tofu/envs/gcp-landing/.terraform.lock.hcl: Generated file
  • infra/tofu/envs/local/.terraform.lock.hcl: Generated file
  • infra/tofu/envs/prod/.terraform.lock.hcl: Generated file
  • infra/tofu/envs/shared/.terraform.lock.hcl: Generated file

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +3 to +5
# OpenTofu has no include mechanism for root modules: `required_providers` is
# only read from the root it lives in. Every root under infra/tofu/ therefore
# carries its own versions.tf. infra/tofu/shared/ is NOT included by anything —
#
# google is held at 6.x to match environments/gcp-* (gcp-gke, gcp-ephemeral,
# gcp-persistent). Before this file existed the root was unconstrained and
# resolved whatever was newest that day — 7.42.0 as of 2026-07-29, a full major
Comment thread infra/tofu/.gitignore
Comment on lines +16 to +18
# Regenerate with the TOFU_VERSION in .github/workflows/tofu-plan.yml:
# tofu providers lock \
# -platform=linux_amd64 -platform=darwin_arm64 -platform=darwin_amd64
@mdheller
mdheller merged commit 09b91fd into main Aug 1, 2026
68 checks passed
mdheller added a commit that referenced this pull request Aug 1, 2026
…alidated bar as envs/*

#1108 pinned providers and committed locks for the four `envs/*` roots CI
validates, and deliberately left the other nine unlocked. This closes that gap
for the six `environments/*` and three `bootstrap/*` roots, so every cloud root
in the tree is offline-validated on the same terms: `required_version` present,
`required_providers` pinned, lock committed, `tofu init -backend=false &&
tofu validate` clean.

All measurement with OpenTofu 1.8.3 — the TOFU_VERSION in tofu-plan.yml —
from a clean .terraform with no plugin cache.

Four asymmetries closed:

* bootstrap/* declared no `required_version`. It was the only tier without one.
  The terraform block moves to a versions.tf in each root, so all 14 roots now
  declare versions in the same filename, and `>= 1.8.0` matches every peer.

* Three google majors in one estate, none of it recorded. bootstrap/gcp moves
  ~> 5.0 -> ~> 6.0, joining environments/gcp-* and (via #1108) envs/*. One
  google major, on purpose. Validated at BOTH 5.45.2 and 6.50.0: clean, no
  warnings either side. Same call for azurerm, which was split 3.x/4.x for the
  same non-reason: bootstrap/azure moves ~> 3.0 -> ~> 4.0 to match
  environments/azure-aks. Also validated at both majors.

* environments/aws-eks pulled tls, time, null and cloudinit transitively from
  terraform-aws-modules/{vpc,eks} under floor-only constraints — `tls >= 3.0.0`
  was resolving 4.3.0 and would have crossed into 5.x unannounced. A floor
  without a ceiling is not a pin. All four are now declared at the root with
  ceilings; tls and null match the constraints already written in
  shared/versions.tf. No resolved version changed.

* bootstrap/aws was the only root validating with a warning:
  aws_s3_bucket_lifecycle_configuration with neither `filter` nor `prefix`,
  which the provider says "will be an error in a future version". `filter {}`
  is the documented way to say "all objects", which is what the rule already
  meant. Warning gone.

Also fixed: environments/gcp-{ephemeral,persistent} carry their own .gitignore
that ignores .terraform.lock.hcl. A nested .gitignore overrides its parent, so
#1108's un-ignore does not reach them and both locks would have been dropped
silently by `git add`. Those two lines are removed; state stays ignored.

Locks use the same platform set as #1108 (linux_amd64, darwin_arm64,
darwin_amd64) — verified 3 h1 hashes per provider across all 21 entries — and
are stable: a second clean init leaves all eight byte-identical.

environments/ibm-iks is deliberately untouched. It is the one root that cannot
init, #1103 fixes the cause and #1107 the validate errors, and both are open
against it. Its lock belongs to whichever of those lands; the generated content
is in the PR body.

Evidence is local only. GitHub Actions is at a spend cap with hundreds of jobs
queued and none running, so no CI run backs any of this.
mdheller added a commit that referenced this pull request Aug 1, 2026
…alidated bar as envs/* (#1115)

#1108 pinned providers and committed locks for the four `envs/*` roots CI
validates, and deliberately left the other nine unlocked. This closes that gap
for the six `environments/*` and three `bootstrap/*` roots, so every cloud root
in the tree is offline-validated on the same terms: `required_version` present,
`required_providers` pinned, lock committed, `tofu init -backend=false &&
tofu validate` clean.

All measurement with OpenTofu 1.8.3 — the TOFU_VERSION in tofu-plan.yml —
from a clean .terraform with no plugin cache.

Four asymmetries closed:

* bootstrap/* declared no `required_version`. It was the only tier without one.
  The terraform block moves to a versions.tf in each root, so all 14 roots now
  declare versions in the same filename, and `>= 1.8.0` matches every peer.

* Three google majors in one estate, none of it recorded. bootstrap/gcp moves
  ~> 5.0 -> ~> 6.0, joining environments/gcp-* and (via #1108) envs/*. One
  google major, on purpose. Validated at BOTH 5.45.2 and 6.50.0: clean, no
  warnings either side. Same call for azurerm, which was split 3.x/4.x for the
  same non-reason: bootstrap/azure moves ~> 3.0 -> ~> 4.0 to match
  environments/azure-aks. Also validated at both majors.

* environments/aws-eks pulled tls, time, null and cloudinit transitively from
  terraform-aws-modules/{vpc,eks} under floor-only constraints — `tls >= 3.0.0`
  was resolving 4.3.0 and would have crossed into 5.x unannounced. A floor
  without a ceiling is not a pin. All four are now declared at the root with
  ceilings; tls and null match the constraints already written in
  shared/versions.tf. No resolved version changed.

* bootstrap/aws was the only root validating with a warning:
  aws_s3_bucket_lifecycle_configuration with neither `filter` nor `prefix`,
  which the provider says "will be an error in a future version". `filter {}`
  is the documented way to say "all objects", which is what the rule already
  meant. Warning gone.

Also fixed: environments/gcp-{ephemeral,persistent} carry their own .gitignore
that ignores .terraform.lock.hcl. A nested .gitignore overrides its parent, so
#1108's un-ignore does not reach them and both locks would have been dropped
silently by `git add`. Those two lines are removed; state stays ignored.

Locks use the same platform set as #1108 (linux_amd64, darwin_arm64,
darwin_amd64) — verified 3 h1 hashes per provider across all 21 entries — and
are stable: a second clean init leaves all eight byte-identical.

environments/ibm-iks is deliberately untouched. It is the one root that cannot
init, #1103 fixes the cause and #1107 the validate errors, and both are open
against it. Its lock belongs to whichever of those lands; the generated content
is in the PR body.

Evidence is local only. GitHub Actions is at a spend cap with hundreds of jobs
queued and none running, so no CI run backs any of this.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants