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
+28
View File
@@ -12,6 +12,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
tfregistry "git.unkin.net/unkin/artifactapi/internal/api/terraform"
v1 "git.unkin.net/unkin/artifactapi/internal/api/v1"
v2 "git.unkin.net/unkin/artifactapi/internal/api/v2"
"git.unkin.net/unkin/artifactapi/internal/cache"
@@ -30,6 +31,7 @@ import (
_ "git.unkin.net/unkin/artifactapi/internal/provider/terraform"
"git.unkin.net/unkin/artifactapi/internal/proxy"
"git.unkin.net/unkin/artifactapi/internal/storage"
"git.unkin.net/unkin/artifactapi/internal/tfsign"
"git.unkin.net/unkin/artifactapi/internal/virtual"
)
@@ -43,6 +45,7 @@ type Server struct {
engine *proxy.Engine
virtEngine *virtual.Engine
localHandler *v2.LocalHandler
tfRegistry *tfregistry.Handler
gc *gc.Collector
}
@@ -67,6 +70,25 @@ func New(cfg *config.Config, version string) (*Server, error) {
virtEngine := virtual.NewEngine(db, engine)
collector := gc.New(db, s3, 1*time.Hour)
// The terraform registry signs with a GPG key. A configured file wins (BYO
// key); otherwise artifactapi generates one on first start and persists it in
// the database so every replica shares it. A failure here must not take the
// server down — the registry just stays disabled.
var signer *tfsign.Signer
if cfg.TFSigningKeyPath != "" {
signer, err = tfsign.Load(cfg.TFSigningKeyPath, cfg.TFSigningKeyPassphrase)
} else {
signer, err = tfsign.LoadOrCreate(context.Background(), db, "terraform-provider")
}
if err != nil {
slog.Warn("terraform provider registry disabled", "error", err)
signer = nil
}
tfRegistry := tfregistry.NewHandler(db, signer, cfg.TFProviderProtocols)
if tfRegistry.Enabled() {
slog.Info("terraform provider registry enabled", "key_id", signer.KeyID())
}
s := &Server{
cfg: cfg,
version: version,
@@ -76,6 +98,7 @@ func New(cfg *config.Config, version string) (*Server, error) {
engine: engine,
virtEngine: virtEngine,
localHandler: localHandler,
tfRegistry: tfRegistry,
gc: collector,
}
@@ -97,6 +120,11 @@ func (s *Server) routes() chi.Router {
r.Get("/", s.handleRoot)
r.Get("/version", s.handleVersion)
// Terraform provider registry: service discovery at the well-known path,
// providers.v1 protocol under /terraform/v1/providers.
r.Get("/.well-known/terraform.json", s.tfRegistry.ServiceDiscovery)
r.Mount(tfregistry.MountPath, s.tfRegistry.Routes())
proxyHandler := v1.NewProxyHandler(s.engine, s.virtEngine, s.db, s.store, s.localHandler)
r.Mount("/api/v1", proxyHandler.Routes())
r.Mount("/v2", proxyHandler.DockerV2Routes())