feat: serve local terraform repos as a provider registry
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful

Local terraform repos already spoke the network mirror protocol, which needs
per-consumer .terraformrc config. This adds the provider registry protocol so
`terraform init` installs from a bare source address
(artifactapi.k8s.../{repo}/{type}) with no client setup.

- serve /.well-known/terraform.json service discovery and the providers.v1
  versions/download endpoints under /terraform/v1/providers
- map the Terraform namespace to the artifactapi repo name and locate the
  provider by type; download_url points back at the existing local file path
- generate SHA256SUMS per version and sign it with a GPG key loaded from
  TF_SIGNING_KEY_PATH; advertise the public key + key id in the download
  response. No key configured -> registry stays disabled (endpoints 404)
- new internal/tfsign (key loading + detached signing) and
  internal/api/terraform (registry handler); export ParseProviderZip for reuse
- add TF_SIGNING_KEY_PATH/PASSPHRASE and TF_PROVIDER_PROTOCOLS config
- unit test signing + verification; dockerised test of the full flow incl.
  signature verification against the advertised key

Also anchor the terraform/ gitignore to the repo root so it stops swallowing
internal/api/terraform and internal/provider/terraform test files (the latter
had gone silently untracked).
This commit is contained in:
2026-07-03 17:46:55 +10:00
parent 3a3b7fe7b7
commit edb6c7c0f7
11 changed files with 939 additions and 2 deletions
+21
View File
@@ -26,6 +26,27 @@ var providerZipRe = regexp.MustCompile(
var semverRe = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9.]+)?$`)
// ParsedProviderZip describes a terraform-provider-{type}_{version}_{os}_{arch}.zip
// filename. Ok is false when the name doesn't match that convention.
type ParsedProviderZip struct {
Type string
Version string
OS string
Arch string
Ok bool
}
// ParseProviderZip extracts the type, version and platform from a provider zip
// filename (the base name, not a full path). It's the canonical parser shared by
// the network-mirror index and the provider registry handler.
func ParseProviderZip(filename string) ParsedProviderZip {
m := providerZipRe.FindStringSubmatch(filename)
if m == nil {
return ParsedProviderZip{}
}
return ParsedProviderZip{Type: m[1], Version: m[2], OS: m[3], Arch: m[4], Ok: true}
}
type Provider struct{}
func (p *Provider) Type() models.PackageType { return models.PackageTerraform }
@@ -0,0 +1,171 @@
package terraform
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.unkin.net/unkin/artifactapi/internal/provider"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
type fakeFileStore struct{ entries []provider.FileEntry }
func (f fakeFileStore) ListFilesByPrefix(_ context.Context, _, prefix string) ([]provider.FileEntry, error) {
var out []provider.FileEntry
for _, e := range f.entries {
if strings.HasPrefix(e.FilePath, prefix) {
out = append(out, e)
}
}
return out, nil
}
func (f fakeFileStore) ListPackages(_ context.Context, _ string) ([]string, error) { return nil, nil }
func TestTFPureFuncs(t *testing.T) {
p := &Provider{}
if p.Classify("hashicorp/aws/versions") != provider.Mutable {
t.Error("versions should be mutable")
}
if p.Classify("hashicorp/aws/terraform-provider-aws_1.0.0_linux_amd64.zip") != provider.Immutable {
t.Error("zip should be immutable")
}
if got := p.UpstreamURL(models.Remote{BaseURL: "https://registry.terraform.io"}, "hashicorp/aws/versions"); got != "https://registry.terraform.io/v1/providers/hashicorp/aws/versions" {
t.Errorf("upstream url %q", got)
}
h, _ := p.AuthHeaders(context.Background(), models.Remote{Username: "u", Password: "p"})
if h.Get("Authorization") == "" {
t.Error("auth header")
}
_ = p.ContentType("x.json")
}
func TestTFValidateUpload(t *testing.T) {
p := &Provider{}
sp, ct, err := p.ValidateUpload("hashicorp/aws/terraform-provider-aws_1.2.3_linux_amd64.zip")
if err != nil || sp != "hashicorp/aws/terraform-provider-aws_1.2.3_linux_amd64.zip" || ct != "application/zip" {
t.Errorf("valid: sp=%q ct=%q err=%v", sp, ct, err)
}
if _, _, err := p.ValidateUpload("too/few"); err == nil {
t.Error("expected error for wrong path depth")
}
if _, _, err := p.ValidateUpload("ns/aws/not-a-provider.zip"); err == nil {
t.Error("expected error for bad filename")
}
if _, _, err := p.ValidateUpload("ns/gcp/terraform-provider-aws_1.0.0_linux_amd64.zip"); err == nil {
t.Error("expected error for type mismatch")
}
}
func TestTFUploadResponse(t *testing.T) {
p := &Provider{}
resp := p.UploadResponse("hashicorp/aws/terraform-provider-aws_1.2.3_linux_amd64.zip", "sha256:abc", 100)
if resp["namespace"] != "hashicorp" || resp["type"] != "aws" || resp["version"] != "1.2.3" || resp["os"] != "linux" || resp["arch"] != "amd64" {
t.Errorf("structured response wrong: %v", resp)
}
fallback := p.UploadResponse("weird/path", "sha256:x", 1)
if fallback["path"] != "weird/path" {
t.Errorf("fallback response wrong: %v", fallback)
}
}
func TestTFRewriteResponse(t *testing.T) {
p := &Provider{}
remote := models.Remote{Name: "tf", ReleasesRemote: "hashicorp-releases"}
if out, _ := p.RewriteResponse([]byte(`{"download_url":"x"}`), models.Remote{}, "http://proxy"); out != nil {
t.Error("no ReleasesRemote should be a no-op")
}
if out, _ := p.RewriteResponse([]byte("not json"), remote, "http://proxy"); out != nil {
t.Error("invalid json should be a no-op")
}
body := []byte(`{"download_url":"https://releases.hashicorp.com/terraform-provider-aws/1.0/aws.zip"}`)
out, err := p.RewriteResponse(body, remote, "http://proxy")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(out), "http://proxy/api/v1/remote/hashicorp-releases/") {
t.Errorf("download_url not rewritten: %s", out)
}
}
func TestTFServeLocalIndex(t *testing.T) {
p := &Provider{}
fs := fakeFileStore{entries: []provider.FileEntry{
{FilePath: "hashicorp/aws/terraform-provider-aws_1.0.0_linux_amd64.zip", ContentHash: "sha256:deadbeef"},
{FilePath: "hashicorp/aws/terraform-provider-aws_1.0.0_darwin_arm64.zip", ContentHash: "sha256:cafe"},
}}
serve := func(path string) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/"+path, nil)
p.ServeLocalIndex(w, r, fs, "repo", path)
return w
}
if w := serve("hashicorp/aws/index.json"); w.Code != 200 || !strings.Contains(w.Body.String(), "1.0.0") {
t.Errorf("index.json: code=%d body=%s", w.Code, w.Body.String())
}
if w := serve("hashicorp/aws/1.0.0.json"); w.Code != 200 || !strings.Contains(w.Body.String(), "linux_amd64") {
t.Errorf("version doc: code=%d body=%s", w.Code, w.Body.String())
}
// Not a terraform index path.
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/x", nil)
if p.ServeLocalIndex(w, r, fs, "repo", "hashicorp/aws/other.txt") {
t.Error("non-index path should return false")
}
if p.ServeLocalIndex(httptest.NewRecorder(), r, fs, "repo", "too/short") {
t.Error("short path should return false")
}
}
func TestTFContentTypeAndEmptyIndex(t *testing.T) {
p := &Provider{}
for path, want := range map[string]string{
"x.zip": "application/zip",
"x.sig": "application/octet-stream",
"index.json": "application/json",
} {
if got := p.ContentType(path); got != want {
t.Errorf("ContentType(%q)=%q want %q", path, got, want)
}
}
// index / version doc with no matching files -> 404.
empty := fakeFileStore{}
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/hashicorp/aws/index.json", nil)
p.ServeLocalIndex(w, r, empty, "repo", "hashicorp/aws/index.json")
if w.Code != http.StatusNotFound {
t.Errorf("empty index should be 404, got %d", w.Code)
}
w = httptest.NewRecorder()
p.ServeLocalIndex(w, r, empty, "repo", "hashicorp/aws/1.0.0.json")
if w.Code != http.StatusNotFound {
t.Errorf("empty version doc should be 404, got %d", w.Code)
}
}
func TestRewriteDownloadURL(t *testing.T) {
// Empty proxy base -> unchanged.
if got := rewriteDownloadURL("https://x/a.zip", "rel", ""); got != "https://x/a.zip" {
t.Errorf("empty base: %q", got)
}
// Unparseable URL -> unchanged.
if got := rewriteDownloadURL("://bad", "rel", "http://p"); got != "://bad" {
t.Errorf("bad url: %q", got)
}
// Normal rewrite.
if got := rewriteDownloadURL("https://cdn/path/a.zip", "rel", "http://p"); got != "http://p/api/v1/remote/rel/path/a.zip" {
t.Errorf("rewrite: %q", got)
}
}
func TestTFGenerateLocalIndexUnsupported(t *testing.T) {
if _, err := (&Provider{}).GenerateLocalIndex(context.Background(), fakeFileStore{}, "r", "x"); err == nil {
t.Error("expected unsupported error")
}
}