Skip to content

feat!: cache tf provider mirror via ctx.download - #16

Open
dominics wants to merge 13 commits into
mainfrom
dominics/provider-mirror-ctx-download
Open

feat!: cache tf provider mirror via ctx.download#16
dominics wants to merge 13 commits into
mainfrom
dominics/provider-mirror-ctx-download

Conversation

@dominics

@dominics dominics commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Background

The mirror was built by shelling out to TF_PLUGIN_CACHE_DIR=... terraform init inside the toolchain repo rule (or previously by using terraform providers mirror). Those downloads flow through the terraform binary, not ctx.download*, so --repository_cache structurally cannot cache them. On an ephemeral runner where the external repo dir isn't persisted, every job re-downloads the full provider set from the registry. In core-infra that is 336 MiB across 11 providers on each of ~62 parallel plan jobs: roughly 20 GiB of registry traffic and 682 separate provider installs per plan run, every one of them a chance for a transient reset to fail a job.

There is a second problem underneath that one. main resolves nothing: mirror entries must be exact pins, because the only thing that could resolve a constraint was the terraform init subprocess. And because that subprocess ran inside a repo rule, whatever it worked out was invisible to everything outside. Terraform's own answer to this is .terraform.lock.hcl. This ruleset had no equivalent.

What

Populate the provider mirror via ctx.download instead of a terraform init subprocess, so provider bytes are content-addressed and served by Bazel's --repository_cache across runs.

Resolve the manifest in the module extension rather than in the download repo, so that what it resolved to is recorded in MODULE.bazel.lock and mirror entries can be version constraints rather than only exact pins.

Replacing terraform's provider installer means reimplementing the parts of it we depend on: service discovery, private registry auth, constraint resolution, and signature-derived hash verification. The first commit does the swap; the rest restore what the swap dropped.

Measured impact

Rolled out to core-infra in rillanetwork/core-infra#141: ~62 parallel plan jobs, 11 providers, 336 MiB of packages per platform, against the warm --repository_cache that setup-bazel's repository-cache: true restores. Cache hits were confirmed on the exact key rather than the prefix fallback.

Terraform init median job median
this branch, exact-key cache hit (2 runs) 47.0s, 47.0s 85s, 85s
main at v1.0.1 (6 runs) 49.0 - 56.5s 86 - 91s

Both warm runs reproduce 47.0s and fall below the whole baseline band; means are 49.9s and 51.1s against a baseline 54.4 - 59.0s. That is about 10% off the init step, under 5% off the job, and roughly 4 minutes of aggregate runner time per plan run. Wall clock does not move: the matrix is fully parallel, so run duration is set by the slowest job rather than the sum.

The speed change is real but minor, and it is not the reason to do this. The reason is that 682 public-registry fetches per plan run collapse into a single ~5s intra-datacentre cache restore, and the transient-reset exposure goes with them. Read the caching as a reliability change that happens to be slightly faster.

The cache cost is modest. In core-infra the repository-cache entry measures 342 MiB, and the repo sits at 0.92 GiB of GitHub's 10 GB per-repo ceiling with two such entries live, so eviction pressure is not a practical concern: one entry exists per distinct MODULE.bazel hash, and unused entries age out after 7 days long before ~29 of them could accumulate. Consumers with a much busier cache should still check, since an evicted entry reverts the gain to the cold path.

How

For each (source, version), in the module extension:

  1. Locate the host's provider API. The two default hosts come from a built-in table; any other is resolved through https://<host>/.well-known/terraform.json.
  2. For a constraint, GET the published version list and select the highest match.
  3. GET the registry download endpoint, which returns download_url and sha256 shasum in one call.

The resulting concrete coordinates are passed to the download repo as the providers_json attribute. The repo rule then does only step 4, and reaches no registry at all:

  1. ctx.download(url, sha256=...) then ctx.extract into the unpacked filesystem-mirror layout mirror/<host>/<ns>/<type>/<version>/<os>_<arch> that downstream terraform init -plugin-dir=<mirror> already consumes.

Steps 2, 3 and 4 are each batched across the whole manifest before anything is awaited, so N providers cost two rounds of latency rather than 2N. (Only ctx.download accepts block = False; download_and_extract does not, hence the separate extract. The zip is staged in the repo dir and deleted as soon as it is unpacked, and sha256 is still passed so the bytes remain cache-served.)

Downstream init runs fully offline; no downstream change needed. Terraform resolves from registry.terraform.io, tofu from registry.opentofu.org.

What lands in MODULE.bazel.lock

Doing resolution in the extension rather than the repo rule is what makes the result visible to bzlmod, and it is the point of the design. No new lock file is introduced; this is the lockfile every Bazel repo already has.

The concrete coordinates, as attributes of the generated download repo, so generatedRepoSpecs states outright which version each constraint selected:

"attributes": {
  "version": "1.9.5", "os": "linux", "arch": "amd64",
  "providers_json": "[{\"source\":\"hashicorp/random\",\"version\":\"3.1.3\", ...}]"
}

The registry's answers, as extension facts:

"facts": {
  "@@rules_tf+//tf:extensions.bzl%tf_repositories": {
    "resolve/registry.terraform.io/hashicorp/random/~> 3.1.0": {"version": "3.1.3"},
    "package/registry.terraform.io/hashicorp/random/3.1.3/linux_amd64": {
      "download_url": "https://releases.hashicorp.com/...linux_amd64.zip",
      "sha256": "bcf7806b..."
    },
    "verified/registry.terraform.io/hashicorp/random/3.1.3": {"zh": ["bcf7806b...", "..."]}
  }
}

A resolve/ fact answers "which version does this constraint select"; a package/ fact answers "where does this version live and what does it hash to"; a verified/ fact answers "which hashes for this version survived a signature check", and is written by the lock target below rather than by resolution. Together they are everything the extension would otherwise ask the registry, so it declares reproducible = True: a second evaluation defines exactly the same repos with no network access, and a warm --repository_cache then serves the whole mirror offline. Being reproducible, the extension does not appear in the lockfile's moduleExtensions section at all; facts are persisted separately.

Two module_ctx constraints shaped the implementation:

  • module_ctx.facts is a lookup exposing only get, not an iterable, and facts are keyed per extension rather than per platform. Coordinates for the other platforms are therefore carried forward by asking for each key by name, over the four platforms in MIRROR_PLATFORMS. Building on each host in turn accumulates one lockfile covering them all, which is the property terraform gets from a multi-platform .terraform.lock.hcl.
  • module_ctx has no delete(), so the small metadata JSONs are left in the extension's working directory rather than in any repo.

Version constraints

Mirror entries currently must be exact pins. They may now also be a constraint (~> 3.1.0, >= 4.0.0, < 4.0.5, comma-joined as AND), resolved against the registry's published version list, selecting the highest match. Exact pins gain full semver, so -prerelease and +build suffixes are accepted. Prereleases are never selected by a constraint, matching terraform; mirroring one needs an exact pin.

A constraint does not let mirror contents drift between builds, because the resolve/ fact holds the selection. Re-resolve deliberately with bazel mod deps --lockfile_mode=refresh. Pinning is still the clearer form, since the manifest then says outright what the mirror holds.

Breaking change: requires Bazel 9

module_ctx.facts does not exist on Bazel 8 (verified against 8.4.2, which fails with 'module_ctx' value has no field or method 'facts'). 9.0.0 supports the full cycle: facts are written to MODULE.bazel.lock and read back on a cold output base. So bazel_compatibility = [">=9.0.0"], the README says Bazel 9, and the CI matrix drops its 8.x row. The repo's own .bazelversion stays at 9.1.0, unchanged from main.

Fact keys deliberately carry no schema version. module_extension(facts_version = ...) would supply one, but it requires Bazel 9.2 and nothing else here needs a release that recent. Changing the shape of a key or its value later means adopting facts_version and raising the floor to 9.2 at that point; there is a comment next to the key builders so that cost is visible where the change would be made.

mirror, mirror_json and every existing manifest are otherwise unaffected: anything that worked on main still works unchanged.

Signature verification (opt-in)

terraform init verified the registry's SHA256SUMS signature against public keys compiled into the terraform binary. The binary carries both "HashiCorp Security" and "HashiCorp Security (Terraform Partner Signing)", so for hashicorp/* and partner providers that verification had a trust root independent of the registry's response. Fetching via ctx.download against a registry-reported shasum does not: an attacker who could compromise the registry or forge TLS to it could substitute a package unnoticed. (Community providers were always registry-anchored either way, since terraform takes their signing key from the registry response.) Losing this is the one real regression in swapping out the subprocess, and it is worth being explicit about.

Starlark has no crypto primitives, so the check cannot be reproduced here. It can be imported. terraform providers lock performs the full verification and records the resulting package hashes as zh: entries, and a zh: value is exactly the sha256 of a release zip. Check every package against those and the hash it is admitted on traces back to a signature rather than to the registry's word. There are two ways to supply them.

tf_providers_lock, a bazel run target, runs the lock command over the resolved manifest and merges what it verified into the facts section of MODULE.bazel.lock, keyed verified/<host>/<ns>/<type>/<version>:

tf_providers_lock(name = "providers_lock")
bazel run //:providers_lock              # re-run when the manifest changes, commit the diff
bazel run //:providers_lock -- --check   # CI gate: fails if the recorded hashes are not current

The hashes land in the same file the coordinates already live in, so nothing new has to be maintained alongside the manifest. The extension reads them back through ctx.facts and checks every package before a byte is fetched. Two details of the design:

  • The manifest comes from the resolved toolchain, not from the tag, so a constraint is locked against the version the extension selected rather than against the text it was written as.
  • Facts must be re-emitted by name to survive, since an extension's facts are replaced wholesale by what it returns. That is also what prunes them: a provider dropped from the manifest takes its hashes with it.

provider_locks still takes .terraform.lock.hcl files directly, for a repository that already generates one for its own terraform workflow. The two sources are merged, so they can be combined.

provider_locks_strict = True now means "every mirror entry must have a verified hash", from either source; uncovered entries warn otherwise.

Under use_tofu = True the target runs tofu providers lock against registry.opentofu.org. Not interchangeable: verified that for random@3.3.2 the two registries' zh: sets have zero overlap, since OpenTofu repackages and re-signs.

One run of the lock command covers every platform. zh: hashes come from a single signed SHA256SUMS document, so -platform only decides which packages are downloaded for the h1: hashes this ignores -- confirmed by observing that one platform still yields all 12 zh: hashes. An earlier draft passed all four platforms and paid 4x the downloads for nothing.

Caveats, documented in docs/mirror.md:

  • zh: hashes cover every platform and the lock does not record which is which, so the check is set membership. A registry-level attacker could still redirect one platform's URL to another platform's genuinely signed package. That is a broken build, not an avenue for unsigned code.
  • A dependency lock holds one version per provider while a mirror may stock several, so the target runs the lock command once per version set (and provider_locks, used by hand, needs one file per set).
  • terraform providers lock authenticates the way terraform does, which is a superset of what the extension can read (HCL .tfrc, credential helpers). A token only terraform can find will lock hashes for a mirror the extension then cannot fetch.

Known rough edges

  • Enforcement depends on a CI flag. Under the default --lockfile_mode=update Bazel re-resolves and rewrites the lockfile when the manifest changes; only --lockfile_mode=error makes an unexpected change fail. A standalone lock file read through ctx.read() would have been enforced unconditionally. This is documented, but it is a convention consumers have to adopt rather than something the ruleset can guarantee.
  • MODULE.bazel.lock is machine-managed, so a pin cannot be hand-edited or commented the way a dedicated lock file could be.
  • Verified hashes are checked whenever a package is resolved, which is the case that matters, but editing the recorded hashes does not by itself re-trigger the check: facts are not an input Bazel invalidates the extension on. Confirmed by injection -- a tampered hash is caught on a cold resolution (a fresh checkout, or CI) and not before.
  • Bootstrapping provider_locks_strict = True is ordered: with no hashes recorded yet the extension fails before the lock target can run, so hashes have to be recorded first. Recovering from a genuine mismatch means dropping the offending verified/ fact, re-running the target, and comparing what it writes.
  • Only the host platform is resolved per run. Unchanged from the TF_PLUGIN_CACHE_DIR behaviour this replaces, and facts now accumulate across hosts.
  • No .terraform.lock.hcl is produced for consumers; the mirror is host-scoped and verified per package.

Test plan

Five workspaces, all wired into CI:

  • tests/bcr covers exact pins, a prerelease pin, two versions of one source, a bounded range resolving to tls@4.0.4, a pessimistic ~> 3.1.0 resolving to random@3.1.3 rather than 3.6.0, and a second constraint on an already-constrained source (which caught a real bug: two constraints on one provider raced two concurrent downloads onto the same output path, now fixed by fetching each version listing once).

  • tests/bcr_mirror_json runs provider_locks_strict = True against a genuinely generated lock. Verified out of band that corrupting a single zh: hash fails the build naming the provider, and that removing a provider block fails the coverage check.

  • tests/bcr_facts gives every entry a registry host that does not resolve, and makes one of them a constraint. It passes only because the facts committed in its MODULE.bazel.lock answer both the version question and the coordinates question, so discovery and metadata never happen. Removing the facts fails on service discovery, confirming the test is not passing by accident. Its lockfile is committed for exactly this reason, and the workspace pins its own .bazelversion: 9.1 and 9.2 disagree on how facts are represented on disk and each rejects the other's, so the fixture is only read by the release that wrote it.

  • tests/bcr_tofu runs the same features against the tofu rule and registry.opentofu.org, including a constraint, and provider_locks_strict = True over its whole manifest against locks generated by tofu providers lock (two files, since the manifest stocks random at two versions). Previously nothing exercised use_tofu = True. This is not redundant with bcr_mirror_json: registry.opentofu.org serves artifacts repackaged from github.com/opentofu/terraform-provider-* and re-signed under OpenTofu's key, so for a given provider version its zh: hashes are disjoint from the terraform registry's -- a lock generated by the other tool would cover nothing the mirror fetched. Verified the same two ways as the terraform suite: corrupting a zh: hash fails naming the provider, and dropping the second lock file fails the coverage check on random@3.1.3.

  • tests/bcr_verified is provider_locks_strict = True with no lock file anywhere: it passes only on the verified/ facts committed in its MODULE.bazel.lock, which //tf:providers_lock put there. Verified out of band, on a cold output base, that corrupting one zh: hash fails naming the provider and that deleting an entry fails the coverage check. CI runs the target with --check there, so the committed hashes must be the ones the lock command reproduces -- an admitted hash is one a signature was checked against, not one that was pasted in. Its lockfile is committed and its .bazelversion pinned for the same reason bcr_facts does both. CI also runs the target for real in tests/bcr_tofu, covering the tofu binary and its registry. The registry-free logic -- manifest parsing, the split into version sets, the merge -- has unit tests in //tf/rules:providers_lock_test.

Also verified: a cold resolution with MODULE.bazel.lock deleted regenerates the facts and factsVersions correctly; the extension is absent from moduleExtensions, as reproducible = True intends; the on-disk layout matches the old TF_PLUGIN_CACHE_DIR output (unpacked, binary directly in <os>_<arch>/); no .terraform, zip, or metadata artifacts leak into the repo; and service discovery works against the real .well-known document (checked by temporarily emptying the seeded host table).

@dominics
dominics force-pushed the dominics/provider-mirror-ctx-download branch from 418ac4d to 6af5df2 Compare July 24, 2026 04:42
@dominics dominics changed the title 🤖 wip feat: cache tf provider mirror via ctx.download feat: cache tf provider mirror via ctx.download Jul 24, 2026
@dominics dominics changed the title feat: cache tf provider mirror via ctx.download feat!: cache tf provider mirror via ctx.download Jul 24, 2026
@dominics dominics self-assigned this Jul 24, 2026
dominics and others added 5 commits July 29, 2026 13:49
The provider mirror was populated by a `terraform init` subprocess
(`TF_PLUGIN_CACHE_DIR`) inside the toolchain repo rule. Because those
downloads flow through the terraform binary rather than `ctx.download*`,
Bazel's `--repository_cache` structurally cannot cache them, so every
build on an ephemeral CI runner re-downloads hundreds of MB of providers
from the registry and is exposed to transient network resets.

Fetch each provider through `ctx.download_and_extract` instead: query the
registry download endpoint for the concrete `download_url` + sha256
`shasum`, then extract into the unpacked filesystem-mirror layout
(`mirror/<host>/<ns>/<type>/<version>/<os>_<arch>`) that downstream
`terraform init -plugin-dir=<mirror>` already consumes. Provider bytes are
now content-addressed and served by `--repository_cache` across runs, and
the downstream init runs fully offline. No downstream change needed.

`parse_mirror_entries` now requires exact `x.y.z` pins: `ctx.download*`
needs a concrete (version, sha) to be reproducible and cacheable, so
ranges/operators are rejected rather than resolved against the registry.
The tofu toolchain resolves from `registry.opentofu.org`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two defects in the ctx.download mirror path.

`_is_exact_version` required exactly three all-numeric dot components,
so registry-published prereleases such as `hashicorp/aws:6.0.0-beta1`
were rejected at load time. A prerelease is an exact, content-
addressable pin; the check only needs to reject range/constraint
syntax. Accept `x.y.z[-prerelease][+build]` per semver, which still
rejects `~>3.0`, `>=1.0.0` and comma-joined constraint lists.

`ctx.download` aborts the build on HTTP failure unless `allow_fail` is
set, so both `if not res.success: fail(...)` branches in
`download_provider_to_mirror` were unreachable and their actionable
messages could never print. Pass `allow_fail` and name the offending
mirror entry in the metadata-fetch message.

Covered by a prerelease pin in the bcr test mirror.
Restores two capabilities that `terraform init` provided and the
ctx.download mirror path dropped.

Registry service discovery: a mirror entry naming a host other than
the two defaults now resolves its provider API through
`https://<host>/.well-known/terraform.json`, rather than assuming
`/v1/providers/` verbatim. Both absolute-URL and host-relative
`providers.v1` values are handled. The default hosts are seeded from a
built-in table so the common case costs no extra round trip, and
lookups are memoized per repository-rule execution.

Private registry credentials: a bearer token is taken from
`TF_TOKEN_<host>` (periods to underscores, hyphens to double
underscores, per terraform's convention), falling back to the
`credentials` block of terraform's JSON credentials file at
`~/.terraform.d/credentials.tfrc.json` or `$TF_CLI_CONFIG_FILE`. The
HCL `.tfrc` format is not read, as Starlark cannot parse it. Reads go
through `ctx.getenv`, which registers the invalidation dependency
without a static `environ` list - the set of hosts is not known until
the manifest is parsed.

The token is attached to registry API requests only. A package URL on
a different host than the registry is fetched unauthenticated, so the
credential is not forwarded to the third-party object stores the
public registries redirect to.

Also fails with a named error when a registry response omits
`download_url` or `shasum` instead of raising a dict KeyError.

Verified by temporarily emptying the seeded host table, forcing the
bcr suite to resolve registry.terraform.io through real service
discovery.
The mirror was fetched one provider at a time: metadata GET, wait,
package download, wait, next. A manifest of N providers paid 2N rounds
of network latency, serialized, on every cold fetch.

Batch instead. Every metadata request is issued before any is awaited,
then every package download likewise, so a manifest costs two rounds
regardless of N. Service discovery stays ahead of the fan-out since it
is a blocking prerequisite of the metadata URLs and is memoized per
host anyway.

Only `ctx.download` accepts `block = False` -- `download_and_extract`
does not -- so packages are downloaded and then extracted locally.
That stages a zip in the repository directory, deleted as soon as it
is unpacked; `sha256` is still passed, so the bytes remain
content-addressed and served by --repository_cache exactly as before.

Collapses the two per-provider call sites into one batched entry point,
`download_providers_to_mirror`, which now owns registry client
construction. Verified the resulting mirror layout is byte-identical
and leaves no zip or metadata files behind.
Restores the range support the ctx.download rewrite dropped, removing
the only breaking change in that series. A mirror entry may again be
written as `>= 4.0.0, < 4.0.5` or `~> 3.1.0` rather than an exact pin.

Constraints are resolved against the registry's published version list
(`/v1/providers/<ns>/<type>/versions`), selecting the highest match.
Terraform's operators are supported, comma-joined as AND, including
`~>`, which bounds the component to the left of the rightmost one
written -- so `~> 3.1.0` is < 3.2.0 while `~> 3.1` is < 4.0.0.
Prereleases are never selected by a constraint, matching terraform;
mirroring one still requires an exact pin.

Version listings are fetched once per provider and shared, not once
per constraint: two constraints on one source would otherwise issue
duplicate requests and race two concurrent downloads onto the same
output path. Entries that resolve to the same version are deduplicated
so the package is not unpacked twice over itself.

Resolution forces the published manifest to be inverted. It was
computed in the module extension from the raw manifest strings and
passed down as a comma-joined string_dict, which would now report the
constraint rather than what the mirror holds. The download repository
instead emits `mirror_versions.bzl` after fetching, and the generated
`@tf_toolchains` BUILD loads it. That drops the `repo_mirrors`
attribute, its join/split encoding, and the dead `{mirror_versions}`
template substitution, which no BUILD template ever referenced.

Pinning stays the documented recommendation, since a constraint lets
mirror contents drift with no change to the manifest. The resolved set
is recorded in `mirror_versions.json` and `mirror_versions.bzl` so the
drift is at least observable.

The bcr suite now covers a bounded range, a pessimistic constraint, a
second constraint on an already-constrained source, and an
unsatisfiable constraint's error.
@dominics
dominics force-pushed the dominics/provider-mirror-ctx-download branch 4 times, most recently from cbbe2b5 to 2aefc30 Compare July 29, 2026 03:06
dominics added 3 commits July 29, 2026 15:11
The tofu download rule is a separate implementation with its own
default registry, and no test workspace used `use_tofu = True`. Every
mirror change in this series was therefore exercised on the terraform
path only, and the tofu equivalents were merely code-symmetric.

Adds tests/bcr_tofu, which runs the same features against the tofu
rule and registry.opentofu.org: exact pins including two versions of
one source, and a bounded constraint resolved against the opentofu
version list.

Also adds tests/bcr_tofu to .bazelignore: without it the root
workspace descends into the new workspace's generated bazel-* symlink
and fails to load packages under it.
The ctx.download mirror admitted a package on whatever sha256 the
registry reported. `terraform init` did better: it verified the
registry's SHA256SUMS signature against public keys compiled into the
terraform binary -- the binary carries both "HashiCorp Security" and
"HashiCorp Security (Terraform Partner Signing)" -- which is a trust
root that does not depend on the registry's answer. Dropping that is a
real regression for hashicorp/* and partner providers against an
attacker who can compromise or forge TLS to the registry.

Starlark cannot verify a GPG signature, so the check cannot be
reproduced here. It can be imported. `terraform providers lock` runs
the full verification and records the resulting package hashes in
`.terraform.lock.hcl` as `zh:` entries, and a `zh:` value is precisely
the sha256 of a release zip. `provider_locks` takes such files and
checks every package hash against them before a byte is fetched, so
the hash a package is admitted on traces back to a signature check.

A lock file holds one version per provider while a mirror may stock
several, hence a list of files. Uncovered entries are enumerated in a
warning by default, since partial coverage is expected of a
multi-version mirror, or are fatal under provider_locks_strict.

`zh:` hashes cover every platform and the lock does not say which is
which, so the check is set membership: a package is admitted if it is
a signed release of that provider version. A registry-level attacker
could still swap one platform's URL for another platform's signed
package -- a broken build, not unsigned code. Documented as such.

tests/bcr_mirror_json now runs strict against a real generated lock.
Verified that corrupting one zh: hash fails the build naming the
provider, and that removing a provider block fails the strict coverage
check.
Resolution ran inside the download repository, where nothing outside
can see what it computed: which version a constraint selected, and
which package URL and hash it was fetched from, were both invisible.
Terraform records exactly that in `.terraform.lock.hcl`. Moving
resolution up into the module extension lets bzlmod record it instead,
in the lockfile every Bazel repo already has, with no second lock file
to maintain.

The extension now does the registry work: service discovery, version
listing for constraints, and the metadata request that yields a
package URL and sha256. It hands the download repo fully concrete
coordinates in `providers_json`, so MODULE.bazel.lock records in
generatedRepoSpecs exactly which version each constraint selected. The
repo rule reaches no registry at all -- known URLs against known
hashes, which keeps every package content-addressed for
--repository_cache.

What the registry answered comes back as extension facts, which bzlmod
persists in that same lockfile under two key shapes: `resolve/<host>/
<ns>/<type>/<spec>` for the version a constraint selected, and
`package/<host>/<ns>/<type>/<version>/<platform>` for where it lives
and what it hashes to. A second evaluation answers both from the
lockfile, so the extension declares reproducible = True and makes no
network requests. Constraints therefore no longer drift between
builds; `bazel mod deps --lockfile_mode=refresh` re-resolves them
deliberately.

Facts are keyed per extension rather than per platform, and
module_ctx.facts is a lookup with no iteration, so coordinates for the
other platforms are carried forward by asking for each key by name --
MIRROR_PLATFORMS enumerates the four a tf toolchain runs on. Building
on each host in turn accumulates one lockfile covering them all, the
property terraform gets from a multi-platform .terraform.lock.hcl.

Fact keys carry no schema version. `module_extension(facts_version =
...)` would supply one but requires Bazel 9.2, and nothing else here
needs a release that recent; changing a key or value shape later means
adopting it and raising the floor then. Noted in a comment next to the
key builders so that cost is visible at the point of change.

provider_locks verification moves along with resolution, since the
package hash is now known in the extension. It is unchanged otherwise.

BREAKING: requires Bazel 9. `module_ctx.facts` does not exist on 8.x,
verified against 8.4.2; 9.0.0 supports the full write-and-read-back
cycle, so bazel_compatibility is >=9.0.0 and the CI matrix drops its
8.x row.

tests/bcr_facts asserts the offline path: every entry names a registry
host that does not resolve, and one is a constraint, so the build
passes only if the facts committed in its MODULE.bazel.lock are
honoured. Verified that removing the facts fails the build on service
discovery. 9.1 and 9.2 disagree on how facts are represented on disk
and each rejects the other's, so that workspace pins its own
.bazelversion rather than taking the version from the CI matrix.
@dominics
dominics force-pushed the dominics/provider-mirror-ctx-download branch from 2aefc30 to d0c1986 Compare July 29, 2026 03:13
provider_locks was only exercised against registry.terraform.io. The
verification code is shared, so the untested part was the assumption
that a lock is a lock -- and that is where the two paths differ.
registry.opentofu.org does not proxy HashiCorp's release zips: it
serves artifacts repackaged from github.com/opentofu/terraform-provider-*
and re-signed under OpenTofu's own key, so for a given provider version
the zh: hashes recorded against registry.opentofu.org are disjoint from
those recorded against registry.terraform.io. Nothing checked that the
mirror's sha256 for a tofu package was among a set generated by `tofu
providers lock`.

tests/bcr_tofu now runs provider_locks_strict = True over its whole
manifest, including the constraint-resolved tls@3.4.0. Two lock files,
because a lock records one version per provider and the manifest stocks
random at both 3.3.2 and 3.1.3; strict coverage makes the second file
load-bearing rather than decorative.

The uncovered-entry message named `terraform providers lock`, which is
the wrong command to run for a tofu toolchain, and mirror.md did not say
that a lock covers only the registry it was generated against.
@dominics
dominics marked this pull request as ready for review July 29, 2026 05:47
dominics and others added 2 commits July 31, 2026 10:32
`provider_locks` established the trust root the ctx.download swap gave up:
a `zh:` hash from `terraform providers lock` survived a signature check
against the keys compiled into the terraform binary, so a package admitted
on one traces back to a signature rather than to the registry's word. But
it took a second file, in a second format, that had to be kept in step
with the manifest by hand -- one lock file per version set, since a
dependency lock holds one version per provider.

Add `tf_providers_lock`, a `bazel run` target that runs the lock command
over the resolved manifest and merges what it verified into the `facts`
section of MODULE.bazel.lock, as `verified/<host>/<ns>/<type>/<version>`.
That is the same file the coordinates already live in, so there is no
second file: the extension reads the hashes back through `ctx.facts` and
checks every package against them before a byte is fetched. Hashes from
facts and from `provider_locks` files are merged, so a repository that
already generates a `.terraform.lock.hcl` can keep pointing at it.

The manifest comes from the resolved toolchain rather than from the tag,
so a constraint is locked against the version the extension selected. A
tofu toolchain runs `tofu providers lock` against registry.opentofu.org,
whose packages are re-signed by OpenTofu and so hash differently.

Facts have to be re-emitted by name to survive, since an extension's facts
are replaced by what it returns -- which also prunes: a provider dropped
from the manifest takes its hashes with it. `provider_locks_strict` now
means "every entry must have a verified hash", from either source, and so
requires hashes to be recorded before it is turned on.

One run of the lock command covers every platform: `zh:` hashes come from
a single signed SHA256SUMS document, and `-platform` only decides which
packages are downloaded for the `h1:` hashes this ignores.

Tests: tests/bcr_verified is strict with no lock file anywhere, passing
only on the hashes committed in its MODULE.bazel.lock; verified out of
band that corrupting one hash and that dropping an entry each fail. CI
runs the target with `--check` there, so the committed hashes must be the
ones the lock command reproduces, and runs it for real in tests/bcr_tofu.
The registry-free logic -- manifest parsing, version sets, the merge --
has unit tests.
Two constraints in one manifest can select the same version, and a
constraint can land on a version an exact pin already names. The dedupe
that collapses them ran before the `resolve/` facts were built, so the
collapsed entry was left with no fact of its own: the lockfile looked
complete while that spec still had to be re-resolved against the
registry on every later evaluation, which a facts-only or offline build
cannot do.

Record each constraint's answer between resolution and the dedupe, and
run the dedupe unconditionally rather than only when the manifest held a
constraint -- two exact pins spelled differently (with and without the
registry host) survive parse_mirror_entries, which dedupes on the source
string, and would otherwise race two downloads onto one output path.

Also, in the harvester, stage the lockfile write beside the target and
move it into place, so an interrupt cannot leave a consumer's committed
MODULE.bazel.lock truncated; fail with the offending key when a
`verified/` fact is malformed rather than raising a bare index error; and
prefix generated required_providers local names so a numeric registry
hostname cannot produce a name terraform rejects.

Cover the pure helpers in utils.bzl with unit tests: the semver
comparator and constraint solver, the lock parser, and the facts
helpers, none of which needed a registry to be tested and none of which
were. The tofu CI step now checks its harvest round-trips and re-resolves
cold, so the facts it wrote are read back rather than assumed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dominics

Copy link
Copy Markdown
Contributor Author

Open for review, but still in the middle of testing this in other repos

dominics and others added 2 commits August 3, 2026 11:28
Resolving only the host left every other platform to append its own
`package/` facts the next time someone built, so a lockfile committed from
a macOS machine gained fourteen entries the moment CI ran it on Linux.
That is not merely churn: it fails any repository that gates CI on a clean
working tree, which is how it turned up -- webrtc-sim#1729 went red on a
`MODULE.bazel.lock` diff after being repointed at this branch.

Resolve metadata for all of MIRROR_PLATFORMS instead, so one build writes
a lockfile that serves every machine on the team. Only the host's package
is still downloaded; the rest cost one small GET each, in flight with the
host's, and the bcr fixture resolves six providers across four platforms
in 4.4s.

Two things are deliberately tolerant. A platform a provider does not
publish is skipped rather than fatal, since nothing was going to fetch it.
And service discovery is attempted only when the host platform needs it: a
host whose API base is unknown and whose host-platform coordinates are
already in the facts is left alone, which is what keeps tests/bcr_facts
resolving against a registry that does not answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Becoming reproducible means the extension no longer writes a
moduleExtensions entry, so the first build on each platform deletes that
platform's stale one -- and a build on one platform cannot delete
another's. In a repository that gates CI on a clean working tree that
surfaces as an unexplained MODULE.bazel.lock diff, which is how it turned
up in webrtc-sim#1729. Say so, and say to delete the entry when bumping.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

1 participant