feat!: cache tf provider mirror via ctx.download - #16
Open
dominics wants to merge 13 commits into
Open
Conversation
dominics
force-pushed
the
dominics/provider-mirror-ctx-download
branch
from
July 24, 2026 04:42
418ac4d to
6af5df2
Compare
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
force-pushed
the
dominics/provider-mirror-ctx-download
branch
4 times, most recently
from
July 29, 2026 03:06
cbbe2b5 to
2aefc30
Compare
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
force-pushed
the
dominics/provider-mirror-ctx-download
branch
from
July 29, 2026 03:13
2aefc30 to
d0c1986
Compare
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
marked this pull request as ready for review
July 29, 2026 05:47
`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>
Contributor
Author
|
Open for review, but still in the middle of testing this in other repos |
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Background
The mirror was built by shelling out to
TF_PLUGIN_CACHE_DIR=... terraform initinside the toolchain repo rule (or previously by usingterraform providers mirror). Those downloads flow through the terraform binary, notctx.download*, so--repository_cachestructurally 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. Incore-infrathat 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.
mainresolves nothing: mirror entries must be exact pins, because the only thing that could resolve a constraint was theterraform initsubprocess. 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.downloadinstead of aterraform initsubprocess, so provider bytes are content-addressed and served by Bazel's--repository_cacheacross 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.lockand 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-infrain rillanetwork/core-infra#141: ~62 parallel plan jobs, 11 providers, 336 MiB of packages per platform, against the warm--repository_cachethatsetup-bazel'srepository-cache: truerestores. Cache hits were confirmed on the exact key rather than the prefix fallback.Terraform initmedianmainat v1.0.1 (6 runs)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-infrathe 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 distinctMODULE.bazelhash, 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:https://<host>/.well-known/terraform.json.download_urland sha256shasumin one call.The resulting concrete coordinates are passed to the download repo as the
providers_jsonattribute. The repo rule then does only step 4, and reaches no registry at all:ctx.download(url, sha256=...)thenctx.extractinto the unpacked filesystem-mirror layoutmirror/<host>/<ns>/<type>/<version>/<os>_<arch>that downstreamterraform 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.downloadacceptsblock = False;download_and_extractdoes not, hence the separate extract. The zip is staged in the repo dir and deleted as soon as it is unpacked, andsha256is still passed so the bytes remain cache-served.)Downstream init runs fully offline; no downstream change needed. Terraform resolves from
registry.terraform.io, tofu fromregistry.opentofu.org.What lands in
MODULE.bazel.lockDoing 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
generatedRepoSpecsstates outright which version each constraint selected:The registry's answers, as extension facts:
A
resolve/fact answers "which version does this constraint select"; apackage/fact answers "where does this version live and what does it hash to"; averified/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 declaresreproducible = True: a second evaluation defines exactly the same repos with no network access, and a warm--repository_cachethen serves the whole mirror offline. Being reproducible, the extension does not appear in the lockfile'smoduleExtensionssection at all; facts are persisted separately.Two
module_ctxconstraints shaped the implementation:module_ctx.factsis a lookup exposing onlyget, 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 inMIRROR_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_ctxhas nodelete(), 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-prereleaseand+buildsuffixes 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 withbazel 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.factsdoes 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 toMODULE.bazel.lockand read back on a cold output base. Sobazel_compatibility = [">=9.0.0"], the README says Bazel 9, and the CI matrix drops its8.xrow. The repo's own.bazelversionstays at 9.1.0, unchanged frommain.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 adoptingfacts_versionand 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_jsonand every existing manifest are otherwise unaffected: anything that worked onmainstill works unchanged.Signature verification (opt-in)
terraform initverified the registry'sSHA256SUMSsignature against public keys compiled into the terraform binary. The binary carries both "HashiCorp Security" and "HashiCorp Security (Terraform Partner Signing)", so forhashicorp/*and partner providers that verification had a trust root independent of the registry's response. Fetching viactx.downloadagainst a registry-reportedshasumdoes 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 lockperforms the full verification and records the resulting package hashes aszh:entries, and azh: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, abazel runtarget, runs the lock command over the resolved manifest and merges what it verified into thefactssection ofMODULE.bazel.lock, keyedverified/<host>/<ns>/<type>/<version>: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.factsand checks every package before a byte is fetched. Two details of the design:provider_locksstill takes.terraform.lock.hclfiles 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 = Truenow means "every mirror entry must have a verified hash", from either source; uncovered entries warn otherwise.Under
use_tofu = Truethe target runstofu providers lockagainstregistry.opentofu.org. Not interchangeable: verified that forrandom@3.3.2the 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 signedSHA256SUMSdocument, so-platformonly decides which packages are downloaded for theh1:hashes this ignores -- confirmed by observing that one platform still yields all 12zh: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.provider_locks, used by hand, needs one file per set).terraform providers lockauthenticates 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
--lockfile_mode=updateBazel re-resolves and rewrites the lockfile when the manifest changes; only--lockfile_mode=errormakes an unexpected change fail. A standalone lock file read throughctx.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.lockis machine-managed, so a pin cannot be hand-edited or commented the way a dedicated lock file could be.provider_locks_strict = Trueis 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 offendingverified/fact, re-running the target, and comparing what it writes.TF_PLUGIN_CACHE_DIRbehaviour this replaces, and facts now accumulate across hosts..terraform.lock.hclis produced for consumers; the mirror is host-scoped and verified per package.Test plan
Five workspaces, all wired into CI:
tests/bcrcovers exact pins, a prerelease pin, two versions of one source, a bounded range resolving totls@4.0.4, a pessimistic~> 3.1.0resolving torandom@3.1.3rather than3.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_jsonrunsprovider_locks_strict = Trueagainst a genuinely generated lock. Verified out of band that corrupting a singlezh:hash fails the build naming the provider, and that removing a provider block fails the coverage check.tests/bcr_factsgives every entry a registry host that does not resolve, and makes one of them a constraint. It passes only because the facts committed in itsMODULE.bazel.lockanswer 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_tofuruns the same features against the tofu rule andregistry.opentofu.org, including a constraint, andprovider_locks_strict = Trueover its whole manifest against locks generated bytofu providers lock(two files, since the manifest stocksrandomat two versions). Previously nothing exerciseduse_tofu = True. This is not redundant withbcr_mirror_json:registry.opentofu.orgserves artifacts repackaged fromgithub.laiyagushi.com/opentofu/terraform-provider-*and re-signed under OpenTofu's key, so for a given provider version itszh: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 azh:hash fails naming the provider, and dropping the second lock file fails the coverage check onrandom@3.1.3.tests/bcr_verifiedisprovider_locks_strict = Truewith no lock file anywhere: it passes only on theverified/facts committed in itsMODULE.bazel.lock, which//tf:providers_lockput there. Verified out of band, on a cold output base, that corrupting onezh:hash fails naming the provider and that deleting an entry fails the coverage check. CI runs the target with--checkthere, 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.bazelversionpinned for the same reasonbcr_factsdoes both. CI also runs the target for real intests/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.lockdeleted regenerates the facts andfactsVersionscorrectly; the extension is absent frommoduleExtensions, asreproducible = Trueintends; the on-disk layout matches the oldTF_PLUGIN_CACHE_DIRoutput (unpacked, binary directly in<os>_<arch>/); no.terraform, zip, or metadata artifacts leak into the repo; and service discovery works against the real.well-knowndocument (checked by temporarily emptying the seeded host table).