feat(modules): optional Terraform module registry with pull-through caching - #32
Merged
Conversation
…aching Adds MODULES_ENABLED (off by default), serving the module registry protocol at /.well-known/terraform.json and /v1/modules/. Version lists, resolved locations, and archives land in the same two-layer cache as provider zips, reusing the TTL revalidation, serve-stale-on-outage, and singleflight coalescing the provider path already has. Unlike providers this is a registry rather than a mirror -- Terraform defines no module mirror protocol -- so consumers must rewrite each module's source to address terrastrata directly. Three things the protocol docs do not prepare you for, all found against the live registry and the real CLI: - registry.terraform.io returns git::https://github.com/OWNER/REPO?ref=<sha> for every module, not the https tarball the spec shows. Those are mapped to the equivalent codeload.github.com tarball, so no git client is needed. - Terraform does not expand the go-getter "//*" subdir glob for registry modules; it records the literal path and fails to read it. The tarball's single wrapper directory is therefore stripped on the way through. - Terraform attaches registry credentials only to registry endpoints, never to the X-Terraform-Get fetch, so the archive endpoint is mounted outside bearer auth. Registering module and provider routes on one ServeMux also panics (overlapping patterns, neither more specific), so they stay on separate muxes. Sources that cannot be fetched (non-GitHub git::, ssh://, s3::) pass through verbatim with X-Cache: BYPASS instead of failing the request, counted by the new terrastrata_module_downloads_total metric. Module archives are cached unverified: this protocol publishes no checksums, unlike provider zips. Path validation moves to internal/pathsafe and the freshness envelope to internal/freshness, now shared by both protocols with no behavior change. The E2E job gains a real terraform init against a registry module, which is the only test that can catch the subdir and archive-shape problems above.
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.
Closes the last open roadmap item. Adds
MODULES_ENABLED(off by default), serving Terraform's module registry protocol at/.well-known/terraform.jsonand/v1/modules/.Module version lists, resolved locations, and archives are cached in the same two-layer cache as provider zips, reusing the existing TTL revalidation, serve-stale-on-outage, and singleflight coalescing.
The catch, up front
Unlike providers this is a registry, not a mirror — Terraform defines no module mirror protocol. Consumers must rewrite each module's
source:Module archives are also cached unverified — the protocol publishes no checksums, so they get no equivalent of the SHA-256 check provider zips receive. Defenses are an https-only fetch and a 512 MiB cap. This is documented prominently in the README.
Three things the spec doesn't prepare you for
All found against the live registry and the real CLI, not assumed:
registry.terraform.ioreturnsgit::https://github.com/OWNER/REPO?ref=<sha>for every module sampled, not theapi.github.com/…//*?archive=tar.gztarball the spec shows. Without handling this, nothing from the public registry would be cached at all. These are mapped onto the equivalentcodeload.github.comtarball — no git client, no new dependency.//*subdir glob for registry modules. It records the literal path.terraform/modules/<name>/*and fails with "Unreadable module subdirectory". So the tarball's single wrapper directory is stripped on the way through (repack.go) rather than papered over with a glob. Deriving the wrapper name fromREPO-REFwould have been simpler but wrong — GitHub strips a leadingvfrom tag refs.ModuleLocationcallsaddRequestCredsonly on the registry request; the go-getter fetch that follows carries noAuthorizationheader. The archive endpoint is therefore mounted outside bearer auth — otherwiseterraform initbreaks wheneverAUTH_TOKENis set.Also: registering the module and provider route patterns on one
ServeMuxpanics at startup (they overlap with neither more specific). Providers stay onmirrorMux, modules on the root mux, with a regression test guarding the split.Sources that can't be fetched (non-GitHub
git::,ssh://,s3::) pass through verbatim withX-Cache: BYPASSrather than failing — visible via the newterrastrata_module_downloads_total{outcome}metric.Refactors
internal/pathsafe(traversal-proof validation) andinternal/freshness(TTL envelope) extracted frominternal/mirrorand shared by both protocols. Pure moves, no behavior change — the existing mirror tests pass untouched.Verification
modules,pathsafe,config, andcmd— protocol parsing, repacking (including thepax_global_headerGitHub emits), coalescing, size cap, auth boundary, and the mux-conflict guard.terraform initagainst the live registry, run locally end to end: module installs at its own root, second init serves everything from cache,module_downloads_total{outcome="cached"} 2with zero bypass.make test, lint,go vet, andgovulncheckall clean.Chart bumped to 0.4.0 (
modules.enabled,modules.upstreamBase).Known limitations
git::sources are cacheable; others pass through.