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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user