feat: serve local terraform repos as a provider registry (#102)
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:
2026-07-03 18:55:35 +10:00
committed by BenVincent
parent 3a3b7fe7b7
commit 936cf8846a
14 changed files with 1152 additions and 2 deletions
+301
View File
@@ -0,0 +1,301 @@
// Package terraform serves local terraform repos as a real Terraform provider
// registry: service discovery, version listing, and GPG-signed downloads, so
// `terraform init` installs from a bare source address with no client config.
package terraform
import (
"encoding/json"
"fmt"
"net/http"
"path"
"sort"
"strings"
"github.com/go-chi/chi/v5"
"git.unkin.net/unkin/artifactapi/internal/database"
tfprov "git.unkin.net/unkin/artifactapi/internal/provider/terraform"
"git.unkin.net/unkin/artifactapi/internal/tfsign"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
// ProvidersV1Path is the base the service-discovery document advertises (Terraform
// appends "{namespace}/{type}/versions" etc). MountPath is the same prefix without
// the trailing slash, for chi.Mount.
const (
ProvidersV1Path = "/terraform/v1/providers/"
MountPath = "/terraform/v1/providers"
)
type Handler struct {
db *database.DB
signer *tfsign.Signer
protocols []string
}
func NewHandler(db *database.DB, signer *tfsign.Signer, protocols string) *Handler {
var protos []string
for _, p := range strings.Split(protocols, ",") {
if p = strings.TrimSpace(p); p != "" {
protos = append(protos, p)
}
}
if len(protos) == 0 {
protos = []string{"5.0", "6.0"}
}
return &Handler{db: db, signer: signer, protocols: protos}
}
// Enabled reports whether a signing key is configured. Without one the registry
// cannot produce the signed SHA256SUMS the protocol requires, so it stays off.
func (h *Handler) Enabled() bool { return h.signer != nil }
func (h *Handler) Routes() chi.Router {
r := chi.NewRouter()
r.Get("/{namespace}/{type}/versions", h.versions)
r.Get("/{namespace}/{type}/{version}/download/{os}/{arch}", h.download)
r.Get("/{namespace}/{type}/{version}/sha256sums", h.sha256sums)
r.Get("/{namespace}/{type}/{version}/sha256sums.sig", h.sha256sumsSig)
return r
}
// ServiceDiscovery answers /.well-known/terraform.json, pointing Terraform at the
// providers.v1 protocol base.
func (h *Handler) ServiceDiscovery(w http.ResponseWriter, r *http.Request) {
if !h.Enabled() {
http.NotFound(w, r)
return
}
writeJSON(w, map[string]string{"providers.v1": ProvidersV1Path})
}
// providerFile is one resolved platform artifact within a repo.
type providerFile struct {
version string
os string
arch string
filePath string // path within the repo, e.g. unkin/artifactapi/...zip
sha256 string // hex, no "sha256:" prefix
}
// resolve finds every provider zip of the given type in the repo (namespace).
// The Terraform source namespace maps to the artifactapi repo name; the provider
// is matched by type across whatever in-repo folder it was uploaded under.
func (h *Handler) resolve(r *http.Request, namespace, typeName string) ([]providerFile, error) {
remote, err := h.db.GetRemote(r.Context(), namespace)
if err != nil || remote.PackageType != models.PackageTerraform {
return nil, nil
}
rows, err := h.db.ListLocalFiles(r.Context(), namespace, 10000, 0)
if err != nil {
return nil, err
}
var out []providerFile
for _, row := range rows {
parsed := tfprov.ParseProviderZip(path.Base(row.FilePath))
if !parsed.Ok || parsed.Type != typeName {
continue
}
out = append(out, providerFile{
version: parsed.Version,
os: parsed.OS,
arch: parsed.Arch,
filePath: row.FilePath,
sha256: strings.TrimPrefix(row.ContentHash, "sha256:"),
})
}
return out, nil
}
func (h *Handler) versions(w http.ResponseWriter, r *http.Request) {
if !h.Enabled() {
http.NotFound(w, r)
return
}
namespace := chi.URLParam(r, "namespace")
typeName := chi.URLParam(r, "type")
files, err := h.resolve(r, namespace, typeName)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if len(files) == 0 {
http.NotFound(w, r)
return
}
// Group platforms by version, de-duplicated and stably ordered.
type platform struct {
OS string `json:"os"`
Arch string `json:"arch"`
}
platforms := map[string]map[string]platform{}
for _, f := range files {
if platforms[f.version] == nil {
platforms[f.version] = map[string]platform{}
}
platforms[f.version][f.os+"_"+f.arch] = platform{OS: f.os, Arch: f.arch}
}
type versionEntry struct {
Version string `json:"version"`
Protocols []string `json:"protocols"`
Platforms []platform `json:"platforms"`
}
out := struct {
Versions []versionEntry `json:"versions"`
}{}
for version, plats := range platforms {
entry := versionEntry{Version: version, Protocols: h.protocols}
for _, p := range plats {
entry.Platforms = append(entry.Platforms, p)
}
sort.Slice(entry.Platforms, func(i, j int) bool {
return entry.Platforms[i].OS+entry.Platforms[i].Arch < entry.Platforms[j].OS+entry.Platforms[j].Arch
})
out.Versions = append(out.Versions, entry)
}
sort.Slice(out.Versions, func(i, j int) bool { return out.Versions[i].Version < out.Versions[j].Version })
writeJSON(w, out)
}
func (h *Handler) download(w http.ResponseWriter, r *http.Request) {
if !h.Enabled() {
http.NotFound(w, r)
return
}
namespace := chi.URLParam(r, "namespace")
typeName := chi.URLParam(r, "type")
version := chi.URLParam(r, "version")
osName := chi.URLParam(r, "os")
arch := chi.URLParam(r, "arch")
files, err := h.resolve(r, namespace, typeName)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
var match *providerFile
for i := range files {
if files[i].version == version && files[i].os == osName && files[i].arch == arch {
match = &files[i]
break
}
}
if match == nil {
http.NotFound(w, r)
return
}
base := baseURL(r)
verBase := fmt.Sprintf("%s%s/%s/%s", base+ProvidersV1Path, namespace, typeName, version)
type gpgKey struct {
KeyID string `json:"key_id"`
ASCIIArmor string `json:"ascii_armor"`
}
resp := struct {
Protocols []string `json:"protocols"`
OS string `json:"os"`
Arch string `json:"arch"`
Filename string `json:"filename"`
DownloadURL string `json:"download_url"`
SHASumsURL string `json:"shasums_url"`
SHASumsSignatureURL string `json:"shasums_signature_url"`
SHASum string `json:"shasum"`
SigningKeys struct {
GPGPublicKeys []gpgKey `json:"gpg_public_keys"`
} `json:"signing_keys"`
}{
Protocols: h.protocols,
OS: match.os,
Arch: match.arch,
Filename: path.Base(match.filePath),
DownloadURL: fmt.Sprintf("%s/api/v1/local/%s/%s", base, namespace, match.filePath),
SHASumsURL: verBase + "/sha256sums",
SHASumsSignatureURL: verBase + "/sha256sums.sig",
SHASum: match.sha256,
}
resp.SigningKeys.GPGPublicKeys = []gpgKey{{
KeyID: h.signer.KeyID(),
ASCIIArmor: h.signer.PublicKeyArmor(),
}}
writeJSON(w, resp)
}
func (h *Handler) sha256sums(w http.ResponseWriter, r *http.Request) {
sums, ok := h.buildSums(w, r)
if !ok {
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Write(sums)
}
func (h *Handler) sha256sumsSig(w http.ResponseWriter, r *http.Request) {
sums, ok := h.buildSums(w, r)
if !ok {
return
}
sig, err := h.signer.Sign(sums)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/octet-stream")
w.Write(sig)
}
// buildSums renders the SHA256SUMS body for one version: one "<hex> <filename>"
// line per platform zip, sorted by filename so the signed bytes are stable.
func (h *Handler) buildSums(w http.ResponseWriter, r *http.Request) ([]byte, bool) {
if !h.Enabled() {
http.NotFound(w, r)
return nil, false
}
namespace := chi.URLParam(r, "namespace")
typeName := chi.URLParam(r, "type")
version := chi.URLParam(r, "version")
files, err := h.resolve(r, namespace, typeName)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return nil, false
}
var lines []string
for _, f := range files {
if f.version != version {
continue
}
lines = append(lines, fmt.Sprintf("%s %s", f.sha256, path.Base(f.filePath)))
}
if len(lines) == 0 {
http.NotFound(w, r)
return nil, false
}
sort.Strings(lines)
return []byte(strings.Join(lines, "\n") + "\n"), true
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}
func baseURL(r *http.Request) string {
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
if fwd := r.Header.Get("X-Forwarded-Proto"); fwd != "" {
scheme = fwd
}
return scheme + "://" + r.Host
}
+186
View File
@@ -0,0 +1,186 @@
package terraform
import (
"bytes"
"context"
"encoding/json"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/go-chi/chi/v5"
"golang.org/x/crypto/openpgp"
"golang.org/x/crypto/openpgp/armor"
"git.unkin.net/unkin/artifactapi/internal/database"
"git.unkin.net/unkin/artifactapi/internal/testsupport"
"git.unkin.net/unkin/artifactapi/internal/tfsign"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
var testDSN string
func TestMain(m *testing.M) {
ctx := context.Background()
dsn, terminate, err := testsupport.StartPostgres(ctx)
if err != nil {
os.Exit(m.Run())
}
testDSN = dsn
code := m.Run()
terminate()
os.Exit(code)
}
// testSigner writes a throwaway armored key and loads it.
func testSigner(t *testing.T) *tfsign.Signer {
t.Helper()
e, err := openpgp.NewEntity("artifactapi test", "tf", "tf@example.com", nil)
if err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
w, _ := armor.Encode(&buf, openpgp.PrivateKeyType, nil)
if err := e.SerializePrivate(w, nil); err != nil {
t.Fatal(err)
}
w.Close()
p := filepath.Join(t.TempDir(), "private-key.asc")
if err := os.WriteFile(p, buf.Bytes(), 0o600); err != nil {
t.Fatal(err)
}
s, err := tfsign.Load(p, "")
if err != nil {
t.Fatal(err)
}
return s
}
func TestProviderRegistryFlow(t *testing.T) {
if testDSN == "" {
t.Skip("Docker unavailable")
}
ctx := context.Background()
db, err := database.New(testDSN)
if err != nil {
t.Fatal(err)
}
defer db.Close()
const repo = "tf-reg" // Terraform namespace == repo name
const filePath = "unkin/artifactapi/terraform-provider-artifactapi_1.2.3_linux_amd64.zip"
const hash = "sha256:983cdb25cb7b976538e4334d26e52dee5f44749b9be1500c760cf5cf66be659b"
const wantSha = "983cdb25cb7b976538e4334d26e52dee5f44749b9be1500c760cf5cf66be659b"
if err := db.CreateRemote(ctx, &models.Remote{Name: repo, PackageType: models.PackageTerraform, RepoType: models.RepoTypeLocal}); err != nil {
t.Fatal(err)
}
if err := db.UpsertBlob(ctx, hash, "blobs/98/3c", 6381007, "application/zip"); err != nil {
t.Fatal(err)
}
if err := db.CreateLocalFile(ctx, repo, filePath, hash); err != nil {
t.Fatal(err)
}
signer := testSigner(t)
h := NewHandler(db, signer, "5.0,6.0")
router := chi.NewRouter()
router.Get("/.well-known/terraform.json", h.ServiceDiscovery)
router.Mount(MountPath, h.Routes())
get := func(p string) *httptest.ResponseRecorder {
req := httptest.NewRequest("GET", p, nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w
}
// Service discovery.
w := get("/.well-known/terraform.json")
if w.Code != 200 {
t.Fatalf("discovery = %d", w.Code)
}
var disc map[string]string
json.Unmarshal(w.Body.Bytes(), &disc)
if disc["providers.v1"] != ProvidersV1Path {
t.Errorf("providers.v1 = %q", disc["providers.v1"])
}
// Versions.
w = get("/terraform/v1/providers/tf-reg/artifactapi/versions")
if w.Code != 200 {
t.Fatalf("versions = %d %s", w.Code, w.Body)
}
var vresp struct {
Versions []struct {
Version string `json:"version"`
Protocols []string `json:"protocols"`
Platforms []map[string]string `json:"platforms"`
} `json:"versions"`
}
json.Unmarshal(w.Body.Bytes(), &vresp)
if len(vresp.Versions) != 1 || vresp.Versions[0].Version != "1.2.3" {
t.Fatalf("unexpected versions: %+v", vresp)
}
if len(vresp.Versions[0].Platforms) != 1 || vresp.Versions[0].Platforms[0]["os"] != "linux" {
t.Fatalf("unexpected platforms: %+v", vresp.Versions[0].Platforms)
}
// Download.
w = get("/terraform/v1/providers/tf-reg/artifactapi/1.2.3/download/linux/amd64")
if w.Code != 200 {
t.Fatalf("download = %d %s", w.Code, w.Body)
}
var dl struct {
Filename string `json:"filename"`
DownloadURL string `json:"download_url"`
SHASumsURL string `json:"shasums_url"`
SHASumsSignatureURL string `json:"shasums_signature_url"`
SHASum string `json:"shasum"`
SigningKeys struct {
GPGPublicKeys []struct {
KeyID string `json:"key_id"`
ASCIIArmor string `json:"ascii_armor"`
} `json:"gpg_public_keys"`
} `json:"signing_keys"`
}
json.Unmarshal(w.Body.Bytes(), &dl)
if dl.SHASum != wantSha {
t.Errorf("shasum = %q", dl.SHASum)
}
wantURL := "http://example.com/api/v1/local/tf-reg/" + filePath
if dl.DownloadURL != wantURL {
t.Errorf("download_url = %q, want %q", dl.DownloadURL, wantURL)
}
if len(dl.SigningKeys.GPGPublicKeys) != 1 || dl.SigningKeys.GPGPublicKeys[0].KeyID != signer.KeyID() {
t.Errorf("signing key mismatch: %+v", dl.SigningKeys)
}
// SHA256SUMS + signature verify against the advertised key.
sums := get("/terraform/v1/providers/tf-reg/artifactapi/1.2.3/sha256sums")
wantLine := wantSha + " terraform-provider-artifactapi_1.2.3_linux_amd64.zip\n"
if sums.Body.String() != wantLine {
t.Errorf("sha256sums = %q, want %q", sums.Body.String(), wantLine)
}
sig := get("/terraform/v1/providers/tf-reg/artifactapi/1.2.3/sha256sums.sig")
keyring, err := openpgp.ReadArmoredKeyRing(bytes.NewReader([]byte(dl.SigningKeys.GPGPublicKeys[0].ASCIIArmor)))
if err != nil {
t.Fatal(err)
}
if _, err := openpgp.CheckDetachedSignature(keyring, bytes.NewReader(sums.Body.Bytes()), bytes.NewReader(sig.Body.Bytes())); err != nil {
t.Errorf("sha256sums.sig did not verify: %v", err)
}
}
func TestRegistryDisabledWithoutSigner(t *testing.T) {
h := NewHandler(nil, nil, "")
router := chi.NewRouter()
router.Get("/.well-known/terraform.json", h.ServiceDiscovery)
req := httptest.NewRequest("GET", "/.well-known/terraform.json", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != 404 {
t.Errorf("disabled discovery = %d, want 404", w.Code)
}
}