Compare commits

...

4 Commits

Author SHA1 Message Date
unkinben 26b405a948 feat: serve local docker repos as a real registry
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
Local docker repos previously had no write path — the /v2 Docker Registry
API only proxied to upstreams. This makes a local docker repo a genuine
registry so `docker push`/`docker pull` (and podman/skopeo/buildah) work
against it directly, matching the project principle that a local repo is the
real thing rather than a mirror.

- Implement the Docker Registry HTTP API V2 write/read half for local docker
  repos: blob uploads (monolithic and chunked POST/PATCH/PUT), manifest push,
  tags list, and blob/manifest GET/HEAD.
- Store blobs and manifests through the existing content-addressable store;
  keep a local_files reference per (repo, image) so the GC does not reap them.
  Tags are mutable (UpsertLocalFile); digests and blobs are immutable.
- Dispatch /v2 reads to the local handler for local docker repos and fall
  through to the upstream proxy otherwise; writes are local-docker only.
- Add UpsertLocalFile for mutable tag references.
- Cover the push/pull round-trip with a dockerised e2e test and unit-test the
  registry path parser. Document the registry in the README.
2026-07-04 22:33:43 +10:00
unkinben 936cf8846a 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>
2026-07-03 18:55:35 +10:00
unkinben 3a3b7fe7b7 feat: redirect / to the web UI (#101)
ci/woodpecker/tag/docker Pipeline was successful
## Why

The web UI ships as a separate image served under \`/ui\` (built with \`BASE_PATH=/ui\`). Hitting the bare domain (e.g. \`https://artifactapi.k8s.syd1.au.unkin.net/\`) returned the API's JSON identity blob instead of the app, so browsers never landed on the UI.

## Changes

- Redirect \`GET /\` to \`/ui/\` (302 Found).
- Preserve the former root JSON (\`{"name","version"}\`) at \`/version\`, so health/monitoring can still read the running version.
- Update the server integration test to assert the redirect and the \`/version\` payload.

Reviewed-on: #101
Co-authored-by: Ben Vincent <ben@unkin.net>
Co-committed-by: Ben Vincent <ben@unkin.net>
2026-07-03 15:00:19 +10:00
unkinben 0ec28660ba fix: prune RPM metadata when a local file is evicted (#100)
ci/woodpecker/tag/docker Pipeline was successful
Follow-up to #99.

## Why

Evicting or deleting a local RPM removed the \`local_files\` row but left its \`rpm_metadata\` behind. Since generated repodata is built from \`rpm_metadata\`, \`primary.xml\` kept advertising a package that no longer exists, producing 404s for clients that tried to fetch it.

## Changes

- Add \`PostDeleteHook\` and \`MetadataDeleter\` provider interfaces (symmetric to the existing \`PostUploadHook\`/\`MetadataStore\`), plus a \`DeleteRPMMetadata\` DB method.
- Implement \`AfterDelete\` in the RPM provider to drop the metadata row for the deleted file.
- Route both local delete paths — the new \`evictLocal\` and the existing files handler's \`remove\` — through a shared \`deleteLocalFile\` helper that removes the file then runs the provider's post-delete hook. Non-RPM providers have no hook, so nothing changes for them.
- Cover the cleanup with a dockerised test.

Reviewed-on: #100
Co-authored-by: Ben Vincent <ben@unkin.net>
Co-committed-by: Ben Vincent <ben@unkin.net>
2026-07-03 14:54:28 +10:00
26 changed files with 2057 additions and 10 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
bin/
terraform/
/terraform/
# e2e-docker fixtures are real package files (.rpm, .tgz, .whl, .zip, ...) that
# are intentionally tracked, overriding any global ignore of those extensions.
+48
View File
@@ -89,6 +89,54 @@ resource "artifactapi_virtual" "helm" {
Provider: [terraform-provider-artifactapi](../terraform-provider-artifactapi)
### Serving providers as a registry
A local `terraform` repo is a real provider registry: upload
`terraform-provider-{type}_{version}_{os}_{arch}.zip` files under
`{namespace}/{type}/`, and Terraform installs them from a bare source address —
no `.terraformrc` mirror config:
```hcl
terraform {
required_providers {
artifactapi = {
source = "artifactapi.k8s.syd1.au.unkin.net/<repo>/<type>"
version = "0.1.2"
}
}
}
```
The Terraform *namespace* segment is the artifactapi repo name; the provider is
matched by *type*. The registry serves service discovery
(`/.well-known/terraform.json`), the `providers.v1` version/download endpoints,
and a GPG-signed `SHA256SUMS` per the provider registry protocol.
Signing needs a GPG key. By default artifactapi generates one on first start and
stores it in the database (`signing_keys` table), so every replica shares it and
there's nothing to provision. To bring your own key instead, point
`TF_SIGNING_KEY_PATH` at an armored private key (optionally
`TF_SIGNING_KEY_PASSPHRASE`), which takes precedence over the generated one.
`TF_PROVIDER_PROTOCOLS` (default `5.0,6.0`) sets the advertised plugin protocols.
### Local docker registry
A local `docker` repo is a real container registry, not a mirror: it serves the
Docker Registry HTTP API V2 for both push and pull, so any client (`docker`,
`podman`, `skopeo`, `buildah`) can use it directly.
```sh
docker tag myapp:latest artifactapi.k8s.syd1.au.unkin.net/docker-internal/myapp:latest
docker push artifactapi.k8s.syd1.au.unkin.net/docker-internal/myapp:latest
docker pull artifactapi.k8s.syd1.au.unkin.net/docker-internal/myapp:latest
```
The first path segment after `/v2/` is the artifactapi repo name; the remainder
is the image name. Blobs and manifests are stored through the shared
content-addressable store (deduplicated by digest, reaped by GC once
unreferenced); tags are mutable references and re-pushing a tag moves it. Blob
uploads support both the monolithic and chunked (`POST`/`PATCH`/`PUT`) flows.
## Access Control
| Field | Default | Behaviour |
+177
View File
@@ -0,0 +1,177 @@
//go:build dockere2e
package e2edocker
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"fmt"
"net/http"
"strings"
"testing"
)
func digestOf(b []byte) string {
sum := sha256.Sum256(b)
return "sha256:" + hex.EncodeToString(sum[:])
}
// pushBlobMonolithic uploads a blob with POST (open session) then PUT?digest
// (whole body) — the monolithic-after-POST flow.
func pushBlobMonolithic(t *testing.T, repo, image string, blob []byte) {
t.Helper()
dgst := digestOf(blob)
resp, body := doRequest(t, http.MethodPost, api("/v2/"+repo+"/"+image+"/blobs/uploads/"), nil, "")
if resp.StatusCode != http.StatusAccepted {
t.Fatalf("start upload: status %d: %s", resp.StatusCode, body)
}
loc := resp.Header.Get("Location")
if loc == "" {
t.Fatalf("start upload: no Location header")
}
resp, body = doRequest(t, http.MethodPut, baseURL()+loc+"?digest="+dgst, blob, "application/octet-stream")
if resp.StatusCode != http.StatusCreated {
t.Fatalf("finish upload: status %d: %s", resp.StatusCode, body)
}
if got := resp.Header.Get("Docker-Content-Digest"); got != dgst {
t.Fatalf("finish upload: digest mismatch: got %q want %q", got, dgst)
}
}
// pushBlobChunked uploads a blob with POST then PATCH (body) then PUT?digest
// (empty) — the chunked flow a real docker daemon uses.
func pushBlobChunked(t *testing.T, repo, image string, blob []byte) {
t.Helper()
dgst := digestOf(blob)
resp, body := doRequest(t, http.MethodPost, api("/v2/"+repo+"/"+image+"/blobs/uploads/"), nil, "")
if resp.StatusCode != http.StatusAccepted {
t.Fatalf("start upload: status %d: %s", resp.StatusCode, body)
}
loc := resp.Header.Get("Location")
resp, body = doRequest(t, http.MethodPatch, baseURL()+loc, blob, "application/octet-stream")
if resp.StatusCode != http.StatusAccepted {
t.Fatalf("patch upload: status %d: %s", resp.StatusCode, body)
}
if got := resp.Header.Get("Range"); got != fmt.Sprintf("0-%d", len(blob)-1) {
t.Fatalf("patch upload: unexpected Range %q", got)
}
loc = resp.Header.Get("Location")
resp, body = doRequest(t, http.MethodPut, baseURL()+loc+"?digest="+dgst, nil, "")
if resp.StatusCode != http.StatusCreated {
t.Fatalf("finish upload: status %d: %s", resp.StatusCode, body)
}
}
// TestLocalDockerPushPull exercises a full container push and pull against a
// local docker repo using the Docker Registry HTTP API V2, the way a docker
// client would: upload the config and layer blobs, push the manifest under a
// tag, then pull the manifest and blobs back byte-identically.
func TestLocalDockerPushPull(t *testing.T) {
createRepo(t, `{"name":"docker-internal","package_type":"docker","repo_type":"local"}`)
defer deleteRepo(t, "docker-internal")
const image = "team/app"
const tag = "v1.0.0"
// /v2/ version check.
resp, _ := doRequest(t, http.MethodGet, api("/v2/"), nil, "")
if resp.StatusCode != http.StatusOK {
t.Fatalf("/v2/ ping: status %d", resp.StatusCode)
}
config := []byte(`{"architecture":"amd64","os":"linux","config":{},"rootfs":{"type":"layers","diff_ids":["sha256:0000000000000000000000000000000000000000000000000000000000000000"]}}`)
layer := bytes.Repeat([]byte("artifactapi-layer-data-"), 4096) // ~90 KB opaque layer
configDigest := digestOf(config)
layerDigest := digestOf(layer)
// A brand-new blob should be absent (this is the client's mount check).
resp, _ = doRequest(t, http.MethodHead, api("/v2/"+"docker-internal/"+image+"/blobs/"+configDigest), nil, "")
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("pre-push blob HEAD: expected 404, got %d", resp.StatusCode)
}
pushBlobMonolithic(t, "docker-internal", image, config)
pushBlobChunked(t, "docker-internal", image, layer)
manifest := []byte(fmt.Sprintf(`{"schemaVersion":2,"mediaType":"application/vnd.docker.distribution.manifest.v2+json","config":{"mediaType":"application/vnd.docker.container.image.v1+json","size":%d,"digest":%q},"layers":[{"mediaType":"application/vnd.docker.image.rootfs.diff.tar.gzip","size":%d,"digest":%q}]}`,
len(config), configDigest, len(layer), layerDigest))
manifestDigest := digestOf(manifest)
manifestType := "application/vnd.docker.distribution.manifest.v2+json"
resp, body := doRequest(t, http.MethodPut, api("/v2/docker-internal/"+image+"/manifests/"+tag), manifest, manifestType)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("push manifest: status %d: %s", resp.StatusCode, body)
}
if got := resp.Header.Get("Docker-Content-Digest"); got != manifestDigest {
t.Fatalf("push manifest: digest %q want %q", got, manifestDigest)
}
// --- pull back ---
// Manifest by tag.
resp, body = doRequest(t, http.MethodGet, api("/v2/docker-internal/"+image+"/manifests/"+tag), nil, "")
if resp.StatusCode != http.StatusOK {
t.Fatalf("pull manifest by tag: status %d: %s", resp.StatusCode, body)
}
if !bytes.Equal(body, manifest) {
t.Fatalf("pulled manifest bytes differ from pushed")
}
if ct := resp.Header.Get("Content-Type"); ct != manifestType {
t.Fatalf("pulled manifest content-type %q want %q", ct, manifestType)
}
if got := resp.Header.Get("Docker-Content-Digest"); got != manifestDigest {
t.Fatalf("pulled manifest digest %q want %q", got, manifestDigest)
}
// Manifest by digest.
resp, body = doRequest(t, http.MethodGet, api("/v2/docker-internal/"+image+"/manifests/"+manifestDigest), nil, "")
if resp.StatusCode != http.StatusOK || !bytes.Equal(body, manifest) {
t.Fatalf("pull manifest by digest: status %d, equal=%v", resp.StatusCode, bytes.Equal(body, manifest))
}
// Blobs by digest.
for _, tc := range []struct {
name string
digest string
want []byte
}{
{"config", configDigest, config},
{"layer", layerDigest, layer},
} {
resp, body = doRequest(t, http.MethodGet, api("/v2/docker-internal/"+image+"/blobs/"+tc.digest), nil, "")
if resp.StatusCode != http.StatusOK {
t.Fatalf("pull %s blob: status %d", tc.name, resp.StatusCode)
}
if !bytes.Equal(body, tc.want) {
t.Fatalf("pulled %s blob bytes differ", tc.name)
}
if got := resp.Header.Get("Docker-Content-Digest"); got != tc.digest {
t.Fatalf("pulled %s blob digest %q want %q", tc.name, got, tc.digest)
}
}
// tags/list reflects the pushed tag.
resp, body = doRequest(t, http.MethodGet, api("/v2/docker-internal/"+image+"/tags/list"), nil, "")
if resp.StatusCode != http.StatusOK {
t.Fatalf("tags/list: status %d: %s", resp.StatusCode, body)
}
if !strings.Contains(string(body), `"`+tag+`"`) {
t.Fatalf("tags/list missing tag %q: %s", tag, body)
}
if !strings.Contains(string(body), `"docker-internal/`+image+`"`) {
t.Fatalf("tags/list wrong repository name: %s", body)
}
// A now-present blob HEAD should succeed (client would skip re-upload).
resp, _ = doRequest(t, http.MethodHead, api("/v2/docker-internal/"+image+"/blobs/"+layerDigest), nil, "")
if resp.StatusCode != http.StatusOK {
t.Fatalf("post-push blob HEAD: expected 200, got %d", resp.StatusCode)
}
}
+2 -2
View File
@@ -7,12 +7,14 @@ require (
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/go-chi/chi/v5 v5.3.0
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.10.0
github.com/minio/minio-go/v7 v7.2.0
github.com/redis/go-redis/v9 v9.20.0
github.com/testcontainers/testcontainers-go v0.42.0
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0
github.com/testcontainers/testcontainers-go/modules/redis v0.42.0
golang.org/x/crypto v0.51.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -45,7 +47,6 @@ require (
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
@@ -96,7 +97,6 @@ require (
go.opentelemetry.io/otel/trace v1.41.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.51.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.44.0 // indirect
+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)
}
}
+473
View File
@@ -0,0 +1,473 @@
package v1
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"sort"
"strings"
"sync"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"git.unkin.net/unkin/artifactapi/internal/database"
"git.unkin.net/unkin/artifactapi/internal/storage"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
// This file implements the write half of the Docker Registry HTTP API V2 for
// *local* docker repositories, so a `docker push` / `docker pull` against
// artifactapi treats a local docker repo as a genuine registry (matching the
// project's "local repos are the real thing" principle) rather than a mirror.
//
// Storage reuses the existing content-addressable primitives:
// - blob and manifest bytes are stored via the CAS (deduplicated by sha256)
// - a local_files row per (repo, "<image>/blobs/<digest>") and
// (repo, "<image>/manifests/<ref>") keeps the blob referenced so the GC
// does not reap it, and lets pulls resolve a reference back to a blob.
// Tags are mutable references (UpsertLocalFile); digests and blobs are
// immutable (CreateLocalFile, tolerating an already-exists on re-push).
const dockerAPIVersionHeader = "registry/2.0"
// uploadSession is an in-progress chunked blob upload, buffered to a temp file
// on disk. Sessions are held in-memory keyed by upload UUID, so a single push's
// PATCH/PUT chunks must be served by the same replica — true for the
// homelab single-instance deployment. Monolithic uploads avoid this entirely.
type uploadSession struct {
file *os.File
size int64
}
type uploadStore struct {
mu sync.Mutex
sessions map[string]*uploadSession
}
func newUploadStore() *uploadStore {
return &uploadStore{sessions: make(map[string]*uploadSession)}
}
func (s *uploadStore) create() (string, *uploadSession, error) {
f, err := os.CreateTemp("", "docker-upload-*")
if err != nil {
return "", nil, err
}
id := uuid.NewString()
sess := &uploadSession{file: f}
s.mu.Lock()
s.sessions[id] = sess
s.mu.Unlock()
return id, sess, nil
}
func (s *uploadStore) get(id string) *uploadSession {
s.mu.Lock()
defer s.mu.Unlock()
return s.sessions[id]
}
func (s *uploadStore) remove(id string) {
s.mu.Lock()
sess := s.sessions[id]
delete(s.sessions, id)
s.mu.Unlock()
if sess != nil {
sess.file.Close()
os.Remove(sess.file.Name())
}
}
// dockerReq is a parsed /v2/<remote>/<image>/... request. kind is one of
// "manifest", "blob", "upload", "tags".
type dockerReq struct {
image string
kind string
ref string // tag, digest, or upload uuid depending on kind
}
// parseDockerPath splits the chi "*" remainder (everything after the repo name)
// into the image name and the registry operation. The image name may itself
// contain slashes, so operations are located by their well-known infixes.
func parseDockerPath(rest string) (dockerReq, bool) {
rest = strings.TrimPrefix(rest, "/")
switch {
case strings.HasSuffix(rest, "/tags/list"):
return dockerReq{image: strings.TrimSuffix(rest, "/tags/list"), kind: "tags"}, true
case rest == "tags/list":
return dockerReq{}, false // no image
}
if i := strings.Index(rest, "/blobs/uploads"); i >= 0 {
image := rest[:i]
ref := strings.TrimPrefix(rest[i+len("/blobs/uploads"):], "/")
return dockerReq{image: image, kind: "upload", ref: ref}, image != ""
}
if i := strings.LastIndex(rest, "/manifests/"); i >= 0 {
return dockerReq{image: rest[:i], kind: "manifest", ref: rest[i+len("/manifests/"):]}, true
}
if i := strings.LastIndex(rest, "/blobs/"); i >= 0 {
return dockerReq{image: rest[:i], kind: "blob", ref: rest[i+len("/blobs/"):]}, true
}
return dockerReq{}, false
}
func isDigest(ref string) bool { return strings.HasPrefix(ref, "sha256:") }
// localDockerRemote returns the repo if name is a local docker repository.
func (h *ProxyHandler) localDockerRemote(r *http.Request, name string) (*models.Remote, bool) {
remote, err := h.db.GetRemote(r.Context(), name)
if err != nil {
return nil, false
}
return remote, remote.RepoType == models.RepoTypeLocal && remote.PackageType == models.PackageDocker
}
func dockerError(w http.ResponseWriter, status int, code, msg string) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Docker-Distribution-Api-Version", dockerAPIVersionHeader)
w.WriteHeader(status)
fmt.Fprintf(w, `{"errors":[{"code":%q,"message":%q}]}`, code, msg)
}
// dockerGet dispatches a registry GET to the local handler for local docker
// repos and falls through to the upstream proxy for everything else.
func (h *ProxyHandler) dockerGet(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "remoteName")
if remote, ok := h.localDockerRemote(r, name); ok {
h.dockerLocalGet(w, r, remote, false)
return
}
h.handleProxy(w, r)
}
func (h *ProxyHandler) dockerHead(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "remoteName")
if remote, ok := h.localDockerRemote(r, name); ok {
h.dockerLocalGet(w, r, remote, true)
return
}
h.handleProxyHead(w, r)
}
func (h *ProxyHandler) dockerPost(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "remoteName")
remote, ok := h.localDockerRemote(r, name)
if !ok {
dockerError(w, http.StatusMethodNotAllowed, "UNSUPPORTED", "push is only supported for local docker repositories")
return
}
h.dockerStartUpload(w, r, remote)
}
func (h *ProxyHandler) dockerPatch(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "remoteName")
remote, ok := h.localDockerRemote(r, name)
if !ok {
dockerError(w, http.StatusMethodNotAllowed, "UNSUPPORTED", "push is only supported for local docker repositories")
return
}
h.dockerPatchUpload(w, r, remote)
}
func (h *ProxyHandler) dockerPut(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "remoteName")
remote, ok := h.localDockerRemote(r, name)
if !ok {
dockerError(w, http.StatusMethodNotAllowed, "UNSUPPORTED", "push is only supported for local docker repositories")
return
}
req, ok := parseDockerPath(chi.URLParam(r, "*"))
if !ok {
dockerError(w, http.StatusNotFound, "NAME_UNKNOWN", "unrecognised registry path")
return
}
switch req.kind {
case "upload":
h.dockerFinishUpload(w, r, remote, req)
case "manifest":
h.dockerPutManifest(w, r, remote, req)
default:
dockerError(w, http.StatusMethodNotAllowed, "UNSUPPORTED", "PUT not supported for this path")
}
}
func (h *ProxyHandler) dockerDelete(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "remoteName")
remote, ok := h.localDockerRemote(r, name)
if !ok {
dockerError(w, http.StatusMethodNotAllowed, "UNSUPPORTED", "delete is only supported for local docker repositories")
return
}
req, ok := parseDockerPath(chi.URLParam(r, "*"))
if !ok || (req.kind != "manifest" && req.kind != "blob") {
dockerError(w, http.StatusNotFound, "NAME_UNKNOWN", "unrecognised registry path")
return
}
filePath := req.image + "/" + req.kind + "s/" + req.ref
if err := h.db.DeleteLocalFile(r.Context(), remote.Name, filePath); err != nil {
dockerError(w, http.StatusInternalServerError, "UNKNOWN", err.Error())
return
}
w.Header().Set("Docker-Distribution-Api-Version", dockerAPIVersionHeader)
w.WriteHeader(http.StatusAccepted)
}
// dockerLocalGet serves manifest / blob / tags-list reads for a local repo.
func (h *ProxyHandler) dockerLocalGet(w http.ResponseWriter, r *http.Request, remote *models.Remote, head bool) {
req, ok := parseDockerPath(chi.URLParam(r, "*"))
if !ok {
dockerError(w, http.StatusNotFound, "NAME_UNKNOWN", "unrecognised registry path")
return
}
switch req.kind {
case "tags":
h.dockerTagsList(w, r, remote, req.image)
case "manifest":
h.dockerServeRef(w, r, remote, req.image+"/manifests/"+req.ref, head, true)
case "blob":
h.dockerServeRef(w, r, remote, req.image+"/blobs/"+req.ref, head, false)
default:
dockerError(w, http.StatusNotFound, "NAME_UNKNOWN", "unrecognised registry path")
}
}
// dockerServeRef streams the blob backing a local_files path. isManifest
// controls only the default content type; the stored blob content type wins.
func (h *ProxyHandler) dockerServeRef(w http.ResponseWriter, r *http.Request, remote *models.Remote, filePath string, head, isManifest bool) {
file, err := h.db.GetLocalFile(r.Context(), remote.Name, filePath)
if err != nil {
dockerError(w, http.StatusInternalServerError, "UNKNOWN", err.Error())
return
}
if file == nil {
code := "BLOB_UNKNOWN"
if isManifest {
code = "MANIFEST_UNKNOWN"
}
dockerError(w, http.StatusNotFound, code, "not found")
return
}
s3Key := storage.BlobKey(file.ContentHash[len("sha256:"):])
reader, info, err := h.store.Download(r.Context(), s3Key)
if err != nil {
dockerError(w, http.StatusInternalServerError, "UNKNOWN", err.Error())
return
}
defer reader.Close()
contentType := info.ContentType
if contentType == "" {
if isManifest {
contentType = "application/vnd.docker.distribution.manifest.v2+json"
} else {
contentType = "application/octet-stream"
}
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Length", fmt.Sprintf("%d", info.Size))
w.Header().Set("Docker-Content-Digest", file.ContentHash)
w.Header().Set("Docker-Distribution-Api-Version", dockerAPIVersionHeader)
w.Header().Set("X-Artifact-Source", "local")
if head {
w.WriteHeader(http.StatusOK)
return
}
w.WriteHeader(http.StatusOK)
io.Copy(w, reader)
}
func (h *ProxyHandler) dockerTagsList(w http.ResponseWriter, r *http.Request, remote *models.Remote, image string) {
prefix := image + "/manifests/"
files, err := h.db.ListLocalFilesByPrefix(r.Context(), remote.Name, prefix)
if err != nil {
dockerError(w, http.StatusInternalServerError, "UNKNOWN", err.Error())
return
}
tags := []string{}
for _, f := range files {
ref := strings.TrimPrefix(f.FilePath, prefix)
if ref == "" || isDigest(ref) {
continue
}
tags = append(tags, ref)
}
sort.Strings(tags)
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Docker-Distribution-Api-Version", dockerAPIVersionHeader)
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, `{"name":%q,"tags":`, remote.Name+"/"+image)
writeJSONStringList(w, tags)
fmt.Fprint(w, "}")
}
func writeJSONStringList(w io.Writer, items []string) {
fmt.Fprint(w, "[")
for i, s := range items {
if i > 0 {
fmt.Fprint(w, ",")
}
fmt.Fprintf(w, "%q", s)
}
fmt.Fprint(w, "]")
}
// dockerStartUpload begins a blob upload. It honours a monolithic
// POST?digest=... (blob in the POST body) and otherwise opens a chunked
// session, returning its Location for the client's PATCH/PUT.
func (h *ProxyHandler) dockerStartUpload(w http.ResponseWriter, r *http.Request, remote *models.Remote) {
req, ok := parseDockerPath(chi.URLParam(r, "*"))
if !ok || req.kind != "upload" {
dockerError(w, http.StatusNotFound, "NAME_UNKNOWN", "unrecognised registry path")
return
}
if digest := r.URL.Query().Get("digest"); digest != "" {
h.dockerCommitBlob(w, r, remote, req.image, digest, r.Body)
return
}
id, _, err := h.uploads.create()
if err != nil {
dockerError(w, http.StatusInternalServerError, "UNKNOWN", err.Error())
return
}
loc := fmt.Sprintf("/v2/%s/%s/blobs/uploads/%s", remote.Name, req.image, id)
w.Header().Set("Location", loc)
w.Header().Set("Docker-Upload-UUID", id)
w.Header().Set("Range", "0-0")
w.Header().Set("Docker-Distribution-Api-Version", dockerAPIVersionHeader)
w.WriteHeader(http.StatusAccepted)
}
func (h *ProxyHandler) dockerPatchUpload(w http.ResponseWriter, r *http.Request, remote *models.Remote) {
req, ok := parseDockerPath(chi.URLParam(r, "*"))
if !ok || req.kind != "upload" || req.ref == "" {
dockerError(w, http.StatusNotFound, "BLOB_UPLOAD_UNKNOWN", "unknown upload")
return
}
sess := h.uploads.get(req.ref)
if sess == nil {
dockerError(w, http.StatusNotFound, "BLOB_UPLOAD_UNKNOWN", "unknown upload")
return
}
n, err := io.Copy(sess.file, r.Body)
if err != nil {
dockerError(w, http.StatusInternalServerError, "UNKNOWN", err.Error())
return
}
sess.size += n
loc := fmt.Sprintf("/v2/%s/%s/blobs/uploads/%s", remote.Name, req.image, req.ref)
w.Header().Set("Location", loc)
w.Header().Set("Docker-Upload-UUID", req.ref)
w.Header().Set("Range", fmt.Sprintf("0-%d", sess.size-1))
w.Header().Set("Docker-Distribution-Api-Version", dockerAPIVersionHeader)
w.WriteHeader(http.StatusAccepted)
}
// dockerFinishUpload completes a chunked upload: appends any final PUT body,
// stores the assembled blob, and verifies its digest.
func (h *ProxyHandler) dockerFinishUpload(w http.ResponseWriter, r *http.Request, remote *models.Remote, req dockerReq) {
digest := r.URL.Query().Get("digest")
if digest == "" {
dockerError(w, http.StatusBadRequest, "DIGEST_INVALID", "digest query parameter required")
return
}
if req.ref == "" {
// Monolithic PUT with no prior session: body is the whole blob.
h.dockerCommitBlob(w, r, remote, req.image, digest, r.Body)
return
}
sess := h.uploads.get(req.ref)
if sess == nil {
dockerError(w, http.StatusNotFound, "BLOB_UPLOAD_UNKNOWN", "unknown upload")
return
}
defer h.uploads.remove(req.ref)
if _, err := io.Copy(sess.file, r.Body); err != nil {
dockerError(w, http.StatusInternalServerError, "UNKNOWN", err.Error())
return
}
if _, err := sess.file.Seek(0, io.SeekStart); err != nil {
dockerError(w, http.StatusInternalServerError, "UNKNOWN", err.Error())
return
}
h.dockerCommitBlob(w, r, remote, req.image, digest, sess.file)
}
// dockerCommitBlob stores blob bytes through the CAS, verifies the client's
// declared digest, and records the per-image local_files reference.
func (h *ProxyHandler) dockerCommitBlob(w http.ResponseWriter, r *http.Request, remote *models.Remote, image, digest string, body io.Reader) {
result, err := h.cas.Store(r.Context(), body, "application/octet-stream")
if err != nil {
dockerError(w, http.StatusInternalServerError, "UNKNOWN", fmt.Sprintf("store failed: %v", err))
return
}
if result.ContentHash != digest {
dockerError(w, http.StatusBadRequest, "DIGEST_INVALID", fmt.Sprintf("digest mismatch: got %s, declared %s", result.ContentHash, digest))
return
}
if err := h.db.UpsertBlob(r.Context(), result.ContentHash, result.S3Key, result.SizeBytes, "application/octet-stream"); err != nil {
dockerError(w, http.StatusInternalServerError, "UNKNOWN", err.Error())
return
}
if err := h.db.CreateLocalFile(r.Context(), remote.Name, image+"/blobs/"+digest, result.ContentHash); err != nil && !errors.Is(err, database.ErrAlreadyExists) {
dockerError(w, http.StatusInternalServerError, "UNKNOWN", err.Error())
return
}
w.Header().Set("Location", fmt.Sprintf("/v2/%s/%s/blobs/%s", remote.Name, image, digest))
w.Header().Set("Docker-Content-Digest", digest)
w.Header().Set("Docker-Distribution-Api-Version", dockerAPIVersionHeader)
w.WriteHeader(http.StatusCreated)
}
// dockerPutManifest stores a manifest and points its reference (tag or digest)
// at it. Tags are mutable so a re-push moves the tag; digests are immutable.
func (h *ProxyHandler) dockerPutManifest(w http.ResponseWriter, r *http.Request, remote *models.Remote, req dockerReq) {
body, err := io.ReadAll(r.Body)
if err != nil {
dockerError(w, http.StatusInternalServerError, "UNKNOWN", err.Error())
return
}
contentType := r.Header.Get("Content-Type")
if contentType == "" {
contentType = "application/vnd.docker.distribution.manifest.v2+json"
}
sum := sha256.Sum256(body)
digest := "sha256:" + hex.EncodeToString(sum[:])
result, err := h.cas.Store(r.Context(), strings.NewReader(string(body)), contentType)
if err != nil {
dockerError(w, http.StatusInternalServerError, "UNKNOWN", fmt.Sprintf("store failed: %v", err))
return
}
if err := h.db.UpsertBlob(r.Context(), result.ContentHash, result.S3Key, result.SizeBytes, contentType); err != nil {
dockerError(w, http.StatusInternalServerError, "UNKNOWN", err.Error())
return
}
// Always addressable by digest (immutable).
if err := h.db.CreateLocalFile(r.Context(), remote.Name, req.image+"/manifests/"+digest, result.ContentHash); err != nil && !errors.Is(err, database.ErrAlreadyExists) {
dockerError(w, http.StatusInternalServerError, "UNKNOWN", err.Error())
return
}
// If pushed under a tag, (re)point the tag at this manifest.
if !isDigest(req.ref) {
if err := h.db.UpsertLocalFile(r.Context(), remote.Name, req.image+"/manifests/"+req.ref, result.ContentHash); err != nil {
dockerError(w, http.StatusInternalServerError, "UNKNOWN", err.Error())
return
}
}
slog.Info("local docker manifest pushed", "repo", remote.Name, "image", req.image, "ref", req.ref, "digest", digest)
w.Header().Set("Location", fmt.Sprintf("/v2/%s/%s/manifests/%s", remote.Name, req.image, req.ref))
w.Header().Set("Docker-Content-Digest", digest)
w.Header().Set("Docker-Distribution-Api-Version", dockerAPIVersionHeader)
w.WriteHeader(http.StatusCreated)
}
+50
View File
@@ -0,0 +1,50 @@
package v1
import "testing"
func TestParseDockerPath(t *testing.T) {
tests := []struct {
name string
rest string
wantOK bool
wantImage string
wantKind string
wantRef string
}{
{"start upload trailing slash", "team/app/blobs/uploads/", true, "team/app", "upload", ""},
{"start upload no slash", "team/app/blobs/uploads", true, "team/app", "upload", ""},
{"patch upload with uuid", "team/app/blobs/uploads/abc-123", true, "team/app", "upload", "abc-123"},
{"single-segment image upload", "app/blobs/uploads/", true, "app", "upload", ""},
{"blob by digest", "team/app/blobs/sha256:deadbeef", true, "team/app", "blob", "sha256:deadbeef"},
{"manifest by tag", "team/app/manifests/v1.0.0", true, "team/app", "manifest", "v1.0.0"},
{"manifest by digest", "team/app/manifests/sha256:cafe", true, "team/app", "manifest", "sha256:cafe"},
{"tags list", "team/app/tags/list", true, "team/app", "tags", ""},
{"leading slash tolerated", "/team/app/manifests/latest", true, "team/app", "manifest", "latest"},
{"deep image name", "a/b/c/manifests/latest", true, "a/b/c", "manifest", "latest"},
{"unrecognised", "team/app/whatever", false, "", "", ""},
{"tags list without image", "tags/list", false, "", "", ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, ok := parseDockerPath(tc.rest)
if ok != tc.wantOK {
t.Fatalf("ok = %v, want %v", ok, tc.wantOK)
}
if !tc.wantOK {
return
}
if got.image != tc.wantImage || got.kind != tc.wantKind || got.ref != tc.wantRef {
t.Fatalf("got %+v, want image=%q kind=%q ref=%q", got, tc.wantImage, tc.wantKind, tc.wantRef)
}
})
}
}
func TestIsDigest(t *testing.T) {
if !isDigest("sha256:abc") {
t.Fatal("sha256: prefix should be a digest")
}
if isDigest("v1.0.0") {
t.Fatal("a tag is not a digest")
}
}
+21 -3
View File
@@ -23,10 +23,20 @@ type ProxyHandler struct {
db *database.DB
store *storage.S3
local *v2.LocalHandler
cas *storage.CAS
uploads *uploadStore
}
func NewProxyHandler(engine *proxy.Engine, virtualEngine *virtual.Engine, db *database.DB, store *storage.S3, local *v2.LocalHandler) *ProxyHandler {
return &ProxyHandler{engine: engine, virtualEngine: virtualEngine, db: db, store: store, local: local}
return &ProxyHandler{
engine: engine,
virtualEngine: virtualEngine,
db: db,
store: store,
local: local,
cas: storage.NewCAS(store),
uploads: newUploadStore(),
}
}
func (h *ProxyHandler) Routes() chi.Router {
@@ -37,12 +47,20 @@ func (h *ProxyHandler) Routes() chi.Router {
return r
}
// DockerV2Routes mounts the Docker Registry HTTP API V2. Reads (GET/HEAD)
// dispatch to a local registry implementation for local docker repos and fall
// through to the upstream proxy otherwise; writes (POST/PATCH/PUT/DELETE) are
// only valid for local docker repos and drive push.
func (h *ProxyHandler) DockerV2Routes() chi.Router {
r := chi.NewRouter()
r.Get("/", h.handleDockerPing)
r.Head("/", h.handleDockerPing)
r.Get("/{remoteName}/*", h.handleProxy)
r.Head("/{remoteName}/*", h.handleProxyHead)
r.Get("/{remoteName}/*", h.dockerGet)
r.Head("/{remoteName}/*", h.dockerHead)
r.Post("/{remoteName}/*", h.dockerPost)
r.Patch("/{remoteName}/*", h.dockerPatch)
r.Put("/{remoteName}/*", h.dockerPut)
r.Delete("/{remoteName}/*", h.dockerDelete)
return r
}
+23 -1
View File
@@ -185,13 +185,35 @@ func (h *LocalHandler) remove(w http.ResponseWriter, r *http.Request) {
repoName := chi.URLParam(r, "name")
filePath := chi.URLParam(r, "*")
if err := h.db.DeleteLocalFile(r.Context(), repoName, filePath); err != nil {
if err := deleteLocalFile(r.Context(), h.db, repoName, filePath); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// deleteLocalFile removes a local file and runs the provider's post-delete hook,
// so provider-derived state (e.g. RPM metadata that feeds generated repodata)
// stops referencing a package that no longer exists.
func deleteLocalFile(ctx context.Context, db *database.DB, repoName, filePath string) error {
if err := db.DeleteLocalFile(ctx, repoName, filePath); err != nil {
return err
}
remote, err := db.GetRemote(ctx, repoName)
if err != nil {
return nil // file is gone; no repo left to resolve a cleanup hook from
}
prov, err := provider.Get(remote.PackageType)
if err != nil {
return nil
}
if hook, ok := prov.(provider.PostDeleteHook); ok {
return hook.AfterDelete(ctx, repoName, filePath, db)
}
return nil
}
func (h *LocalHandler) DB() *database.DB {
return h.db
}
@@ -0,0 +1,75 @@
package v2
import (
"context"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"git.unkin.net/unkin/artifactapi/internal/database"
"git.unkin.net/unkin/artifactapi/internal/provider"
_ "git.unkin.net/unkin/artifactapi/internal/provider/rpm" // register the rpm provider so its PostDeleteHook runs
"git.unkin.net/unkin/artifactapi/pkg/models"
)
// TestLocalEvictCleansRPMMetadata verifies that evicting an RPM from a local
// repo also removes the derived rpm_metadata row, so generated repodata stops
// listing the deleted package.
func TestLocalEvictCleansRPMMetadata(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 = "rpm-evict-cleanup"
if err := db.CreateRemote(ctx, &models.Remote{Name: repo, PackageType: models.PackageRPM, RepoType: models.RepoTypeLocal}); err != nil {
t.Fatal(err)
}
const hash = "sha256:bb22"
const path = "Packages/example-0.1.0-1.x86_64.rpm"
if err := db.UpsertBlob(ctx, hash, "blobs/bb/22", 2048, "application/x-rpm"); err != nil {
t.Fatal(err)
}
if err := db.CreateLocalFile(ctx, repo, path, hash); err != nil {
t.Fatal(err)
}
if err := db.InsertRPMMetadata(ctx, &provider.RPMMetadata{
RepoName: repo, FilePath: path, ContentHash: hash,
Name: "example", Version: "0.1.0", Release: "1", Arch: "x86_64",
Requires: []provider.RPMDep{}, Provides: []provider.RPMDep{},
Files: []provider.RPMFile{}, Changelogs: []provider.RPMChangelog{},
}); err != nil {
t.Fatal(err)
}
h := NewObjectsHandler(db)
router := chi.NewRouter()
router.Route("/locals/{name}/objects", func(r chi.Router) {
r.Delete("/*", h.LocalRoutes().ServeHTTP)
})
del := httptest.NewRequest("DELETE", "/locals/"+repo+"/objects/"+path, nil)
dw := httptest.NewRecorder()
router.ServeHTTP(dw, del)
if dw.Code != 204 {
t.Fatalf("evict = %d, want 204", dw.Code)
}
if f, _ := db.GetLocalFile(ctx, repo, path); f != nil {
t.Fatalf("local file still present after evict: %+v", f)
}
entries, err := db.ListRPMMetadataEntries(ctx, repo)
if err != nil {
t.Fatal(err)
}
if len(entries) != 0 {
t.Fatalf("rpm_metadata still present after evict: %+v", entries)
}
}
+1 -1
View File
@@ -75,7 +75,7 @@ func (h *ObjectsHandler) evictLocal(w http.ResponseWriter, r *http.Request) {
repoName := chi.URLParam(r, "name")
path := chi.URLParam(r, "*")
if err := h.db.DeleteLocalFile(r.Context(), repoName, path); err != nil {
if err := deleteLocalFile(r.Context(), h.db, repoName, path); err != nil {
http.Error(w, fmt.Sprintf("evict failed: %v", err), http.StatusInternalServerError)
return
}
+12
View File
@@ -24,6 +24,14 @@ type Config struct {
S3Bucket string
S3Secure bool
S3Region string
// Terraform provider registry signing. When TFSigningKeyPath points at a
// readable armored GPG private key, artifactapi serves local terraform
// repos as a real provider registry (service discovery + signed
// SHA256SUMS). Left empty, the registry endpoints stay disabled.
TFSigningKeyPath string
TFSigningKeyPassphrase string
TFProviderProtocols string
}
func (c *Config) DatabaseDSN() string {
@@ -59,6 +67,10 @@ func Load() (*Config, error) {
S3Bucket: getenv("MINIO_BUCKET", "artifacts"),
S3Secure: s3Secure,
S3Region: getenv("MINIO_REGION", ""),
TFSigningKeyPath: getenv("TF_SIGNING_KEY_PATH", ""),
TFSigningKeyPassphrase: getenv("TF_SIGNING_KEY_PASSPHRASE", ""),
TFProviderProtocols: getenv("TF_PROVIDER_PROTOCOLS", "5.0,6.0"),
}
return cfg, nil
+14
View File
@@ -38,6 +38,20 @@ func (db *DB) CreateLocalFile(ctx context.Context, repoName, filePath, contentHa
return nil
}
// UpsertLocalFile inserts a local file or repoints an existing path at a new
// blob. Unlike CreateLocalFile it never errors on a duplicate path — it is for
// mutable references such as Docker tags, where re-pushing a tag must move it to
// the newly-pushed manifest rather than being rejected as an overwrite.
func (db *DB) UpsertLocalFile(ctx context.Context, repoName, filePath, contentHash string) error {
_, err := db.Pool.Exec(ctx, `
INSERT INTO local_files (repo_name, file_path, content_hash)
VALUES ($1, $2, $3)
ON CONFLICT (repo_name, file_path)
DO UPDATE SET content_hash = EXCLUDED.content_hash, created_at = NOW()
`, repoName, filePath, contentHash)
return err
}
func (db *DB) GetLocalFile(ctx context.Context, repoName, filePath string) (*LocalFile, error) {
row := db.Pool.QueryRow(ctx, `
SELECT id, repo_name, file_path, content_hash, created_at
+7
View File
@@ -158,6 +158,13 @@ func (db *DB) migrate() error {
);
CREATE INDEX IF NOT EXISTS idx_rpm_metadata_repo ON rpm_metadata(repo_name);
CREATE TABLE IF NOT EXISTS signing_keys (
purpose TEXT PRIMARY KEY,
private_key_armor TEXT NOT NULL,
key_id TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
`)
return err
}
+5
View File
@@ -32,6 +32,11 @@ func (db *DB) InsertRPMMetadata(ctx context.Context, meta *provider.RPMMetadata)
return err
}
func (db *DB) DeleteRPMMetadata(ctx context.Context, repoName, filePath string) error {
_, err := db.Pool.Exec(ctx, `DELETE FROM rpm_metadata WHERE repo_name = $1 AND file_path = $2`, repoName, filePath)
return err
}
type RPMMetadataRow struct {
RepoName string
FilePath string
+35
View File
@@ -0,0 +1,35 @@
package database
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
)
// GetSigningKey returns the stored armored private key and key id for a purpose.
// found is false when no key has been generated yet.
func (db *DB) GetSigningKey(ctx context.Context, purpose string) (armor, keyID string, found bool, err error) {
row := db.Pool.QueryRow(ctx, `
SELECT private_key_armor, key_id FROM signing_keys WHERE purpose = $1
`, purpose)
if err := row.Scan(&armor, &keyID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return "", "", false, nil
}
return "", "", false, err
}
return armor, keyID, true, nil
}
// InsertSigningKeyIfAbsent stores a freshly generated key, doing nothing if
// another replica already inserted one. Callers re-read with GetSigningKey to
// pick up whichever key won the race.
func (db *DB) InsertSigningKeyIfAbsent(ctx context.Context, purpose, armor, keyID string) error {
_, err := db.Pool.Exec(ctx, `
INSERT INTO signing_keys (purpose, private_key_armor, key_id)
VALUES ($1, $2, $3)
ON CONFLICT (purpose) DO NOTHING
`, purpose, armor, keyID)
return err
}
+31
View File
@@ -0,0 +1,31 @@
package database
import "testing"
func TestSigningKeyRoundTripAndIdempotency(t *testing.T) {
requireDB(t)
const purpose = "terraform-provider-test"
// Absent to start.
if _, _, found, err := testDB.GetSigningKey(ctx(), purpose); err != nil || found {
t.Fatalf("expected no key, got found=%v err=%v", found, err)
}
if err := testDB.InsertSigningKeyIfAbsent(ctx(), purpose, "ARMOR-1", "KEYID1"); err != nil {
t.Fatal(err)
}
// A second insert must not overwrite (models the replica race).
if err := testDB.InsertSigningKeyIfAbsent(ctx(), purpose, "ARMOR-2", "KEYID2"); err != nil {
t.Fatal(err)
}
armor, keyID, found, err := testDB.GetSigningKey(ctx(), purpose)
if err != nil || !found {
t.Fatalf("expected key, found=%v err=%v", found, err)
}
if armor != "ARMOR-1" || keyID != "KEYID1" {
t.Errorf("key was overwritten: armor=%q key_id=%q", armor, keyID)
}
}
+10
View File
@@ -53,10 +53,20 @@ type PostUploadHook interface {
AfterUpload(ctx context.Context, repoName, storagePath, contentHash string, blobs BlobReader, db MetadataStore)
}
// PostDeleteHook lets a provider clean up derived state (e.g. RPM metadata that
// feeds generated repodata) after a local file is removed.
type PostDeleteHook interface {
AfterDelete(ctx context.Context, repoName, storagePath string, db MetadataDeleter) error
}
type MetadataStore interface {
InsertRPMMetadata(ctx context.Context, meta *RPMMetadata) error
}
type MetadataDeleter interface {
DeleteRPMMetadata(ctx context.Context, repoName, filePath string) error
}
type RPMMetadataReader interface {
ListRPMMetadataEntries(ctx context.Context, repoName string) ([]RPMMetadata, error)
}
+9
View File
@@ -151,6 +151,15 @@ func (p *Provider) AfterUpload(ctx context.Context, repoName, storagePath, conte
slog.Info("rpm metadata: parsed", "repo", repoName, "name", meta.Name, "version", meta.Version, "arch", meta.Arch)
}
func (p *Provider) AfterDelete(ctx context.Context, repoName, storagePath string, db provider.MetadataDeleter) error {
if err := db.DeleteRPMMetadata(ctx, repoName, storagePath); err != nil {
slog.Error("rpm metadata: delete failed", "repo", repoName, "path", storagePath, "error", err)
return err
}
slog.Info("rpm metadata: deleted", "repo", repoName, "path", storagePath)
return nil
}
func rpmDepFromEntry(e rpmlib.Dependency) provider.RPMDep {
dep := provider.RPMDep{Name: e.Name()}
if e.Flags() != 0 {
+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")
}
}
+35
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,
}
@@ -95,6 +118,12 @@ func (s *Server) routes() chi.Router {
r.Get("/health", s.handleHealth)
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())
@@ -143,7 +172,13 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"status":"ok"}`)
}
// handleRoot sends browsers landing on the bare domain to the web UI, which is
// served under /ui. The service identity that used to live here is at /version.
func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/ui/", http.StatusFound)
}
func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, `{"name":"artifactapi","version":"%s"}`, s.version)
+21 -2
View File
@@ -129,13 +129,32 @@ func req(t *testing.T, method, path string, body string) (*http.Response, []byte
return resp, b
}
// reqNoRedirect issues a request without following redirects so the response's
// status and Location header can be asserted directly.
func reqNoRedirect(t *testing.T, method, path string) *http.Response {
t.Helper()
rq, _ := http.NewRequest(method, testTS.URL+path, nil)
client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
}}
resp, err := client.Do(rq)
if err != nil {
t.Fatalf("%s %s: %v", method, path, err)
}
resp.Body.Close()
return resp
}
func TestServerHealthAndRoot(t *testing.T) {
requireStack(t)
if resp, _ := req(t, "GET", "/health", ""); resp.StatusCode != 200 {
t.Errorf("health: %d", resp.StatusCode)
}
if resp, b := req(t, "GET", "/", ""); resp.StatusCode != 200 || !strings.Contains(string(b), "test-version") {
t.Errorf("root: %d %s", resp.StatusCode, b)
if resp := reqNoRedirect(t, "GET", "/"); resp.StatusCode != http.StatusFound || resp.Header.Get("Location") != "/ui/" {
t.Errorf("root redirect: %d %q", resp.StatusCode, resp.Header.Get("Location"))
}
if resp, b := req(t, "GET", "/version", ""); resp.StatusCode != 200 || !strings.Contains(string(b), "test-version") {
t.Errorf("version: %d %s", resp.StatusCode, b)
}
if resp, _ := req(t, "GET", "/api/v2/health", ""); resp.StatusCode != 200 {
t.Errorf("health v2: %d", resp.StatusCode)
+173
View File
@@ -0,0 +1,173 @@
// Package tfsign loads a GPG signing key and produces the detached signatures
// the Terraform provider registry protocol requires over SHA256SUMS files.
package tfsign
import (
"bytes"
"context"
"fmt"
"os"
"strings"
"golang.org/x/crypto/openpgp"
"golang.org/x/crypto/openpgp/armor"
)
// KeyStore persists a generated signing key. *database.DB satisfies it.
type KeyStore interface {
GetSigningKey(ctx context.Context, purpose string) (armor, keyID string, found bool, err error)
InsertSigningKeyIfAbsent(ctx context.Context, purpose, armor, keyID string) error
}
// LoadOrCreate returns a signer for purpose, generating and persisting a new key
// the first time it is needed. It is safe across replicas: a lost insert race
// just re-reads whichever key won.
func LoadOrCreate(ctx context.Context, store KeyStore, purpose string) (*Signer, error) {
armored, _, found, err := store.GetSigningKey(ctx, purpose)
if err != nil {
return nil, err
}
if !found {
newArmor, keyID, err := Generate()
if err != nil {
return nil, err
}
if err := store.InsertSigningKeyIfAbsent(ctx, purpose, newArmor, keyID); err != nil {
return nil, err
}
if armored, _, _, err = store.GetSigningKey(ctx, purpose); err != nil {
return nil, err
}
}
return LoadArmored(armored, "")
}
// Signer holds a decrypted GPG entity and exposes what the registry download
// response needs: a detached signature, the armored public key, and the key ID.
type Signer struct {
entity *openpgp.Entity
publicASCII string
keyID string
}
// Load reads an armored private key from path, decrypting it with passphrase if
// the key is protected. A blank path returns (nil, nil): a nil *Signer means the
// caller should fall back to another source (e.g. a DB-stored key).
func Load(path, passphrase string) (*Signer, error) {
if path == "" {
return nil, nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("open signing key: %w", err)
}
return fromArmor(string(data), passphrase, path)
}
// LoadArmored builds a signer from an in-memory armored private key, e.g. one
// read from the database. A blank key returns (nil, nil).
func LoadArmored(armored, passphrase string) (*Signer, error) {
if armored == "" {
return nil, nil
}
return fromArmor(armored, passphrase, "stored key")
}
// Generate creates a fresh signing keypair and returns the armored private key
// (to persist) and its uppercase key id.
func Generate() (armoredPrivateKey, keyID string, err error) {
entity, err := openpgp.NewEntity("artifactapi terraform registry", "provider signing", "artifactapi@localhost", nil)
if err != nil {
return "", "", err
}
var buf bytes.Buffer
w, err := armor.Encode(&buf, openpgp.PrivateKeyType, nil)
if err != nil {
return "", "", err
}
if err := entity.SerializePrivate(w, nil); err != nil {
return "", "", err
}
if err := w.Close(); err != nil {
return "", "", err
}
return buf.String(), strings.ToUpper(entity.PrimaryKey.KeyIdString()), nil
}
func fromArmor(armored, passphrase, src string) (*Signer, error) {
keyring, err := openpgp.ReadArmoredKeyRing(strings.NewReader(armored))
if err != nil {
return nil, fmt.Errorf("read signing key: %w", err)
}
if len(keyring) == 0 {
return nil, fmt.Errorf("signing key (%s) contains no entities", src)
}
entity := keyring[0]
if entity.PrivateKey == nil {
return nil, fmt.Errorf("signing key (%s) has no private key material", src)
}
if entity.PrivateKey.Encrypted {
if err := decrypt(entity, passphrase); err != nil {
return nil, err
}
}
pub, err := armorPublicKey(entity)
if err != nil {
return nil, err
}
return &Signer{
entity: entity,
publicASCII: pub,
keyID: entity.PrimaryKey.KeyIdString(),
}, nil
}
// decrypt unlocks the entity's private key and all subkeys with the passphrase.
func decrypt(entity *openpgp.Entity, passphrase string) error {
pw := []byte(passphrase)
if err := entity.PrivateKey.Decrypt(pw); err != nil {
return fmt.Errorf("decrypt signing key: %w", err)
}
for _, sub := range entity.Subkeys {
if sub.PrivateKey != nil && sub.PrivateKey.Encrypted {
_ = sub.PrivateKey.Decrypt(pw)
}
}
return nil
}
func armorPublicKey(entity *openpgp.Entity) (string, error) {
var buf bytes.Buffer
w, err := armor.Encode(&buf, openpgp.PublicKeyType, nil)
if err != nil {
return "", err
}
if err := entity.Serialize(w); err != nil {
return "", err
}
if err := w.Close(); err != nil {
return "", err
}
return buf.String(), nil
}
// Sign returns a binary detached signature over message, matching the
// SHA256SUMS.sig format Terraform verifies.
func (s *Signer) Sign(message []byte) ([]byte, error) {
var buf bytes.Buffer
if err := openpgp.DetachSign(&buf, s.entity, bytes.NewReader(message), nil); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// PublicKeyArmor returns the ASCII-armored public key for the registry's
// signing_keys response.
func (s *Signer) PublicKeyArmor() string { return s.publicASCII }
// KeyID returns the 16-hex-char uppercase key ID Terraform matches against the
// signature's issuer.
func (s *Signer) KeyID() string { return strings.ToUpper(s.keyID) }
+155
View File
@@ -0,0 +1,155 @@
package tfsign
import (
"bytes"
"context"
"os"
"path/filepath"
"regexp"
"testing"
"golang.org/x/crypto/openpgp"
"golang.org/x/crypto/openpgp/armor"
)
// armoredPrivateKey generates a throwaway armored private key for tests.
func armoredPrivateKey(t *testing.T) string {
t.Helper()
e, err := openpgp.NewEntity("artifactapi test", "tf registry", "tf@example.com", nil)
if err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
w, err := armor.Encode(&buf, openpgp.PrivateKeyType, nil)
if err != nil {
t.Fatal(err)
}
if err := e.SerializePrivate(w, nil); err != nil {
t.Fatal(err)
}
w.Close()
return buf.String()
}
func writeKey(t *testing.T, contents string) string {
t.Helper()
p := filepath.Join(t.TempDir(), "private-key.asc")
if err := os.WriteFile(p, []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
return p
}
func TestLoadSignAndVerify(t *testing.T) {
path := writeKey(t, armoredPrivateKey(t))
s, err := Load(path, "")
if err != nil {
t.Fatal(err)
}
if s == nil {
t.Fatal("expected a signer")
}
if !regexp.MustCompile(`^[0-9A-F]{16}$`).MatchString(s.KeyID()) {
t.Errorf("key id %q is not 16 uppercase hex chars", s.KeyID())
}
msg := []byte("deadbeef terraform-provider-x_1.0.0_linux_amd64.zip\n")
sig, err := s.Sign(msg)
if err != nil {
t.Fatal(err)
}
// The advertised public key must verify the signature over the same bytes.
keyring, err := openpgp.ReadArmoredKeyRing(bytes.NewReader([]byte(s.PublicKeyArmor())))
if err != nil {
t.Fatal(err)
}
if _, err := openpgp.CheckDetachedSignature(keyring, bytes.NewReader(msg), bytes.NewReader(sig)); err != nil {
t.Errorf("signature did not verify: %v", err)
}
}
func TestGenerateAndLoadArmored(t *testing.T) {
priv, keyID, err := Generate()
if err != nil {
t.Fatal(err)
}
if !regexp.MustCompile(`^[0-9A-F]{16}$`).MatchString(keyID) {
t.Errorf("generated key id %q malformed", keyID)
}
s, err := LoadArmored(priv, "")
if err != nil {
t.Fatal(err)
}
if s.KeyID() != keyID {
t.Errorf("loaded key id %q != generated %q", s.KeyID(), keyID)
}
msg := []byte("abc terraform-provider-x_1.0.0_linux_amd64.zip\n")
sig, err := s.Sign(msg)
if err != nil {
t.Fatal(err)
}
keyring, _ := openpgp.ReadArmoredKeyRing(bytes.NewReader([]byte(s.PublicKeyArmor())))
if _, err := openpgp.CheckDetachedSignature(keyring, bytes.NewReader(msg), bytes.NewReader(sig)); err != nil {
t.Errorf("signature did not verify: %v", err)
}
}
// memStore is an in-memory KeyStore that records how many keys it accepted.
type memStore struct {
armor, keyID string
found bool
inserts int
}
func (m *memStore) GetSigningKey(_ context.Context, _ string) (string, string, bool, error) {
return m.armor, m.keyID, m.found, nil
}
func (m *memStore) InsertSigningKeyIfAbsent(_ context.Context, _, armor, keyID string) error {
if !m.found { // ON CONFLICT DO NOTHING
m.armor, m.keyID, m.found = armor, keyID, true
m.inserts++
}
return nil
}
func TestLoadOrCreateGeneratesOnceThenReuses(t *testing.T) {
store := &memStore{}
first, err := LoadOrCreate(context.Background(), store, "terraform-provider")
if err != nil || first == nil {
t.Fatalf("first LoadOrCreate: signer=%v err=%v", first, err)
}
second, err := LoadOrCreate(context.Background(), store, "terraform-provider")
if err != nil || second == nil {
t.Fatalf("second LoadOrCreate: signer=%v err=%v", second, err)
}
if store.inserts != 1 {
t.Errorf("expected exactly one key generated, got %d", store.inserts)
}
if first.KeyID() != second.KeyID() {
t.Errorf("key id changed between loads: %q vs %q", first.KeyID(), second.KeyID())
}
}
func TestLoadEmptyPathDisabled(t *testing.T) {
s, err := Load("", "")
if err != nil {
t.Fatal(err)
}
if s != nil {
t.Error("empty path should yield a nil (disabled) signer")
}
}
func TestLoadMissingFile(t *testing.T) {
if _, err := Load(filepath.Join(t.TempDir(), "nope.asc"), ""); err == nil {
t.Error("expected an error for a missing key file")
}
}