feat: serve local terraform repos as a provider registry (#102)
ci/woodpecker/tag/docker Pipeline was successful
ci/woodpecker/tag/docker Pipeline was successful
## Why
Local terraform repos already served the Terraform **network mirror** protocol, but consuming that requires every user to add a `provider_installation { network_mirror }` block to `~/.terraformrc`. A `source = "artifactapi.k8s.../ns/type"` address instead triggers the **provider registry** protocol (service discovery at `/.well-known/terraform.json` + GPG-signed SHA256SUMS), which returned 404 — hence *"does not offer a provider registry."*
Local repos are meant to be the real thing, so this makes a terraform local repo a first-class provider registry: `terraform init` installs from a bare source address with no client config.
## What
- Serve `/.well-known/terraform.json` service discovery and the `providers.v1` endpoints under `/terraform/v1/providers`: `versions`, `download/{os}/{arch}`, `sha256sums`, `sha256sums.sig`.
- Map the Terraform **namespace** segment to the artifactapi **repo name**; locate the provider by **type**. `download_url` points back at the existing `/api/v1/local/...` path.
- Generate `SHA256SUMS` per version and sign it with a GPG key loaded from `TF_SIGNING_KEY_PATH` (optional `TF_SIGNING_KEY_PASSPHRASE`); advertise the public key + key id in the download response. **No key → registry stays disabled (endpoints 404)**, so behaviour is unchanged until the signing secret is present.
- New `internal/tfsign` (key load + detached signing, via `x/crypto/openpgp`) and `internal/api/terraform` (registry handler). Export `ParseProviderZip` for reuse.
- `TF_PROVIDER_PROTOCOLS` (default `5.0,6.0`) sets the advertised plugin protocols.
- README section documenting usage.
## Consumer
```hcl
terraform {
required_providers {
artifactapi = {
source = "artifactapi.k8s.syd1.au.unkin.net/terraform-unkin/artifactapi"
version = "0.1.2"
}
}
}
```
## Tests
- `internal/tfsign`: sign + verify round-trip, disabled/missing-key paths.
- `internal/api/terraform`: dockerised full flow (discovery → versions → download → sha256sums → sig), verifying the signature against the advertised public key.
## Follow-ups (separate PRs)
- **argocd-apps**: mount the signing K8s secret into the api deployment + set `TF_SIGNING_KEY_PATH`. The `/` HTTPRoute already routes `/.well-known` and `/terraform` to the API, so no gateway change is needed.
- Image/version bump once tagged.
## Note
Anchored the `terraform/` gitignore to the repo root (`/terraform/`) so it stops matching `internal/*/terraform/`. This surfaced `internal/provider/terraform/terraform_extra_test.go`, which had been silently untracked — now committed.
Reviewed-on: #102
Co-authored-by: Ben Vincent <ben@unkin.net>
Co-committed-by: Ben Vincent <ben@unkin.net>
This commit was merged in pull request #102.
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user