Compare commits

..

24 Commits

Author SHA1 Message Date
unkinben 1009bc7c69 test: database closed-db error paths + pure goproxy/generic/helm/repotype gaps
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline failed
2026-07-03 13:55:30 +10:00
unkinben 2ae12d9aef test: local upload store-failure branches (kill minio mid-test) 2026-07-03 13:53:13 +10:00
unkinben 1d97628fbd test: rpm serve errors, terraform rewriteDownloadURL, pypi merger, s3 stat, pattern-validation 400 2026-07-03 13:51:17 +10:00
unkinben 3e590bea2b test: classifier branches, docker provider, pypi index serving, bearer/checkUpstream variants 2026-07-03 13:48:27 +10:00
unkinben 053fbd70e2 test: provider-not-found and missing-blob download error branches 2026-07-03 13:45:19 +10:00
unkinben 03a29f2c0a test: ttlFor, HEAD 500/cached-index, rpm full-metadata xml, generic content-type 2026-07-03 13:42:17 +10:00
unkinben cfd0bae515 test: server Run, upstream 500/401 branches, virtual dead members, local DB accessor 2026-07-03 13:38:49 +10:00
unkinben 6029f19b86 test: proxy HTTP error branches (403/502) + revalidation upstream error 2026-07-03 13:35:40 +10:00
unkinben 2658167346 test: circuit breaker states, bearer cache-hit, blob dedup, terraform/storage branches 2026-07-03 13:33:24 +10:00
unkinben 4ec3d7b59e test: v2 handler error paths (closed db), v1 scheme, rpm content-type/flag helpers 2026-07-03 13:29:23 +10:00
unkinben 1842042241 test: RunOnListener, local pypi virtual merge, v2 download, waitForStore/Unwrap 2026-07-03 13:25:54 +10:00
unkinben 6bcda813fd test: probe branches, local upload errors, upstream errors, rpm/gc error paths 2026-07-03 13:22:24 +10:00
unkinben 3d80837539 test: bearer-token flow (engine) + docker/head/virtual/probe/events (server) 2026-07-03 13:18:51 +10:00
unkinben 915ec910c8 test: expand server test (pypi/rpm local, object evict, validation errors) 2026-07-03 13:15:52 +10:00
unkinben ba4a0e3b43 test: gc sweep integration (delete old orphan, empty no-op) 2026-07-03 13:11:55 +10:00
unkinben 21579c1ec0 test: proxy engine edge cases + storage/server minio retry
Cover denied, HEAD (cached/uncached/denied), stale-on-error, circuit-open,
and mutable revalidation (304) paths. proxy ~8->58%.
2026-07-03 13:11:09 +10:00
unkinben 696a1955b3 test: wait for minio readiness endpoint (avoid init-race flake) 2026-07-03 13:05:40 +10:00
unkinben a6830d6d9f test: wait for postgres readiness log twice (avoid connection-reset flake) 2026-07-03 13:03:40 +10:00
unkinben 6ba78caf4c test: full-stack server integration test
Drives the whole HTTP API against real Postgres/Redis/MinIO + a mock
upstream: health, remote CRUD, proxy miss/hit, local upload/download,
virtual helm merge, objects, stats, probe. Exercises server + api/v1 +
api/v2 + proxy + virtual engines together.
2026-07-03 12:59:39 +10:00
unkinben 5e7500ed04 test: storage (MinIO) and cache (Redis) integration tests
storage 0->79%, cache 0->92%, via the testsupport container harness.
2026-07-03 12:58:00 +10:00
unkinben bf31714e08 test: testcontainers harness + database integration tests
Add internal/testsupport (Postgres/Redis/MinIO container helpers, Ryuk
disabled) and full database coverage: remotes, artifacts/blobs, local
files, virtuals, stats, rpm metadata, orphan/cold cleanup. database
0->83%.
2026-07-03 12:55:51 +10:00
unkinben db663e00d7 test: cover virtual mergers and pkg/client
virtual mergers (helm/pypi merge, dedup, URL rewriting, registry) and the
API client (all endpoints via httptest, error/decode paths). pkg/client
0->96%, virtual 49->57%.
2026-07-03 12:45:49 +10:00
unkinben b6091c859d test: cover rpm provider (metadata parse, repodata generation)
rpm 2.7% -> 83.6%, pure-go via a real RPM testdata fixture and fake
BlobReader/MetadataStore/RPMMetadataReader implementations.
2026-07-03 12:43:21 +10:00
unkinben 428c6d0e97 test: unit tests for pure provider/config/auth packages
First increment toward 90% core-package coverage. Adds Docker-free unit
tests taking these packages to full or near-full coverage:
- provider/npm, provider/alpine, provider/puppet: 100%
- provider/pypi: index generation via a fake FileStore, upload validation,
  name parsing, classification, rewrite
- config: defaults, overrides, DSN, invalid port
- auth: basic header with/without credentials

Infra-backed packages (database, storage, cache, proxy engine, api
handlers, server) still need the testcontainers batch to reach 90%.
2026-07-03 12:35:05 +10:00
53 changed files with 29 additions and 2878 deletions
+1 -5
View File
@@ -1,6 +1,2 @@
bin/
/terraform/
# e2e-docker fixtures are real package files (.rpm, .tgz, .whl, .zip, ...) that
# are intentionally tracked, overriding any global ignore of those extensions.
!e2e-docker/fixtures/**
terraform/
+1 -6
View File
@@ -1,4 +1,4 @@
.PHONY: build test lint fmt e2e docker-e2e docker docker-ui compose clean tidy check-go
.PHONY: build test lint fmt e2e docker docker-ui compose clean tidy check-go
BINARY := bin/artifactapi
MODULE := git.unkin.net/unkin/artifactapi
@@ -28,11 +28,6 @@ fmt: check-go
e2e: check-go
TESTCONTAINERS_RYUK_DISABLED=true go test -tags=e2e -race -count=1 -timeout=5m ./e2e/...
# Build the container, bring up the full docker-compose stack + a mock upstream,
# and run the black-box suite against the running product.
docker-e2e: check-go
./scripts/docker-e2e.sh
docker:
docker build -t artifactapi:$(VERSION) .
-48
View File
@@ -89,54 +89,6 @@ 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 |
-18
View File
@@ -1,18 +0,0 @@
# Overlay for the dockerised end-to-end suite (scripts/docker-e2e.sh).
# Adds a static mock upstream that the artifactapi container proxies, so the
# caching tests are hermetic and need no internet access.
services:
mockupstream:
image: nginx:alpine
volumes:
- ./e2e-docker/fixtures:/usr/share/nginx/html:ro,z
# No host port needed: only the artifactapi container talks to it, and the
# tests compare served bytes against the on-disk fixtures.
artifactapi:
# The host port is set via ARTIFACTAPI_PORT (see scripts/docker-e2e.sh),
# defaulting to 8000; the e2e run uses 8001 to avoid colliding with a
# locally-running instance.
depends_on:
mockupstream:
condition: service_started
+1 -1
View File
@@ -2,7 +2,7 @@ services:
artifactapi:
build: .
ports:
- "${ARTIFACTAPI_PORT:-8000}:8000"
- "8000:8000"
environment:
LISTEN_ADDR: ":8000"
DBHOST: postgres
-39
View File
@@ -1,39 +0,0 @@
# Dockerised end-to-end suite
Black-box tests that run against a fully **containerised** artifactapi stack
(built image + Postgres + Redis + MinIO) plus a static mock upstream. Unlike the
in-process `e2e/` suite (testcontainers, server run in-process), these only speak
HTTP to the running product, so they exercise the shipped container image.
## Run
```bash
make docker-e2e # build image, compose up, run suite, compose down
```
`scripts/docker-e2e.sh` builds and starts `docker-compose.yml` +
`docker-compose.e2e.yml`, waits for `/health`, then runs
`go test -tags=dockere2e ./e2e-docker/...` and tears everything down.
The stack publishes artifactapi on host port **8001** (to avoid colliding with a
local instance on 8000). Override with `ARTIFACTAPI_URL` to point the tests at an
already-running stack.
## Coverage
- **Repository lifecycle** — add / change / delete for remote, local and virtual repos.
- **Caching** — one immutable artifact per remote package type (generic, docker,
helm, pypi, npm, rpm, alpine, puppet, terraform, goproxy) proxied through the
mock upstream: first fetch `X-Artifact-Source: remote`, second `cache`, bytes
verified against the origin fixture.
- **Local uploads** — generic (upload/download), pypi (wheel + generated `simple/`
index), rpm (real package + **automatic repodata** generation).
- **Virtual repositories** — pypi simple-index merge and helm `index.yaml` merge
across two members.
## Fixtures
`fixtures/` is served by the mock upstream at its web root. Paths mirror each
provider's upstream URL layout (e.g. `v2/...` for docker, `v1/providers/...` for
terraform). The RPM under `fixtures/rpmrepo/Packages/` is a real package so the
rpm provider can parse its metadata for repodata generation.
-76
View File
@@ -1,76 +0,0 @@
//go:build dockere2e
package e2edocker
import (
"bytes"
"fmt"
"net/http"
"testing"
)
// TestCachingPerProvider proxies one immutable artifact for every remote
// package type through the mock upstream and asserts: first fetch is served
// from the remote, the second from cache, and the bytes match the origin.
func TestCachingPerProvider(t *testing.T) {
cases := []struct {
pkgType string
// path is the request path under /api/v1/remote/<name>/. The provider
// derives the upstream URL from it (docker prepends /v2/, terraform
// prepends /v1/providers/), and the fixture lives at that resolved path.
path string
fixture string
}{
{"generic", "blobs/hello.bin", "blobs/hello.bin"},
{"npm", "mypkg/-/mypkg-1.0.0.tgz", "mypkg/-/mypkg-1.0.0.tgz"},
{"helm", "charts/mychart-1.0.0.tgz", "charts/mychart-1.0.0.tgz"},
{"pypi", "packages/foo-1.0-py3-none-any.whl", "packages/foo-1.0-py3-none-any.whl"},
{"rpm", "rpmrepo/Packages/e2e-testpkg-1.0-1.noarch.rpm", "rpmrepo/Packages/e2e-testpkg-1.0-1.noarch.rpm"},
{"alpine", "alpine/x86_64/testpkg-1.0-r0.apk", "alpine/x86_64/testpkg-1.0-r0.apk"},
{"puppet", "puppet-releases/author-mod-1.0.0.tar.gz", "puppet-releases/author-mod-1.0.0.tar.gz"},
{"goproxy", "goproxy/example.com/mod/@v/v1.0.0.zip", "goproxy/example.com/mod/@v/v1.0.0.zip"},
{"terraform", "hashicorp/aws/download/pkg.zip", "v1/providers/hashicorp/aws/download/pkg.zip"},
{"docker", "library/testimg/blobs/blobdata", "v2/library/testimg/blobs/blobdata"},
}
for _, tc := range cases {
t.Run(tc.pkgType, func(t *testing.T) {
name := "cache-" + tc.pkgType
createRepo(t, fmt.Sprintf(`{
"name": %q,
"package_type": %q,
"repo_type": "remote",
"base_url": %q,
"stale_on_error": true
}`, name, tc.pkgType, mockUpstream()))
defer deleteRepo(t, name)
want := fixtureBytes(t, tc.fixture)
url := api("/api/v1/remote/" + name + "/" + tc.path)
// First fetch: from remote.
resp, body := doRequest(t, http.MethodGet, url, nil, "")
if resp.StatusCode != http.StatusOK {
t.Fatalf("first fetch: status %d: %s", resp.StatusCode, body)
}
if src := resp.Header.Get("X-Artifact-Source"); src != "remote" {
t.Fatalf("first fetch source = %q, want remote", src)
}
if !bytes.Equal(body, want) {
t.Fatalf("first fetch body mismatch: got %d bytes, want %d", len(body), len(want))
}
// Second fetch: from cache, identical bytes.
resp, body = doRequest(t, http.MethodGet, url, nil, "")
if resp.StatusCode != http.StatusOK {
t.Fatalf("second fetch: status %d: %s", resp.StatusCode, body)
}
if src := resp.Header.Get("X-Artifact-Source"); src != "cache" {
t.Fatalf("second fetch source = %q, want cache", src)
}
if !bytes.Equal(body, want) {
t.Fatalf("cached body mismatch: got %d bytes, want %d", len(body), len(want))
}
})
}
}
-177
View File
@@ -1,177 +0,0 @@
//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)
}
}
Binary file not shown.
-1
View File
@@ -1 +0,0 @@
hello artifactapi generic blob
Binary file not shown.
-8
View File
@@ -1,8 +0,0 @@
apiVersion: v1
entries:
alpha:
- name: alpha
version: 1.0.0
urls:
- charts/alpha-1.0.0.tgz
generated: "2026-01-01T00:00:00Z"
-8
View File
@@ -1,8 +0,0 @@
apiVersion: v1
entries:
beta:
- name: beta
version: 2.0.0
urls:
- charts/beta-2.0.0.tgz
generated: "2026-01-01T00:00:00Z"
Binary file not shown.
-108
View File
@@ -1,108 +0,0 @@
//go:build dockere2e
// Package e2edocker holds the black-box end-to-end suite that runs against a
// fully dockerised artifactapi stack (see scripts/docker-e2e.sh). Unlike the
// in-process e2e suite, these tests only speak HTTP to the running container.
package e2edocker
import (
"bytes"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func baseURL() string {
if v := os.Getenv("ARTIFACTAPI_URL"); v != "" {
return strings.TrimRight(v, "/")
}
return "http://localhost:8000"
}
// mockUpstream is the base URL the artifactapi *container* uses to reach the
// static mock upstream. It is resolved on the compose network, not the host.
func mockUpstream() string {
if v := os.Getenv("MOCK_UPSTREAM_INTERNAL"); v != "" {
return strings.TrimRight(v, "/")
}
return "http://mockupstream"
}
func api(path string) string { return baseURL() + path }
func fixtureBytes(t *testing.T, rel string) []byte {
t.Helper()
b, err := os.ReadFile(filepath.Join("fixtures", rel))
if err != nil {
t.Fatalf("read fixture %s: %v", rel, err)
}
return b
}
func doRequest(t *testing.T, method, url string, body []byte, contentType string) (*http.Response, []byte) {
t.Helper()
var r io.Reader
if body != nil {
r = bytes.NewReader(body)
}
req, err := http.NewRequest(method, url, r)
if err != nil {
t.Fatalf("%s %s: %v", method, url, err)
}
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("%s %s: %v", method, url, err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
return resp, respBody
}
func createRepo(t *testing.T, jsonBody string) {
t.Helper()
resp, body := doRequest(t, http.MethodPost, api("/api/v2/remotes"), []byte(jsonBody), "application/json")
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create repo: status %d: %s", resp.StatusCode, body)
}
}
func deleteRepo(t *testing.T, name string) {
t.Helper()
doRequest(t, http.MethodDelete, api("/api/v2/remotes/"+name), nil, "")
}
func createVirtual(t *testing.T, jsonBody string) {
t.Helper()
resp, body := doRequest(t, http.MethodPost, api("/api/v2/virtuals"), []byte(jsonBody), "application/json")
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create virtual: status %d: %s", resp.StatusCode, body)
}
}
func deleteVirtual(t *testing.T, name string) {
t.Helper()
doRequest(t, http.MethodDelete, api("/api/v2/virtuals/"+name), nil, "")
}
// getEventually retries a GET until it returns 200 or the deadline passes. Used
// for asynchronously-generated artifacts (e.g. rpm repodata after upload).
func getEventually(t *testing.T, url string, timeout time.Duration) (*http.Response, []byte) {
t.Helper()
deadline := time.Now().Add(timeout)
var resp *http.Response
var body []byte
for {
resp, body = doRequest(t, http.MethodGet, url, nil, "")
if resp.StatusCode == http.StatusOK || time.Now().After(deadline) {
return resp, body
}
time.Sleep(250 * time.Millisecond)
}
}
-93
View File
@@ -1,93 +0,0 @@
//go:build dockere2e
package e2edocker
import (
"bytes"
"net/http"
"strings"
"testing"
"time"
)
func uploadFile(t *testing.T, repo, filePath string, body []byte, contentType string) {
t.Helper()
url := api("/api/v2/remotes/" + repo + "/files/" + filePath)
resp, respBody := doRequest(t, http.MethodPut, url, body, contentType)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("upload %s: status %d: %s", filePath, resp.StatusCode, respBody)
}
}
// TestLocalGenericUpload uploads a generic file and downloads it back.
func TestLocalGenericUpload(t *testing.T) {
createRepo(t, `{"name":"local-generic","package_type":"generic","repo_type":"local"}`)
defer deleteRepo(t, "local-generic")
content := []byte("artifactapi local generic upload payload")
uploadFile(t, "local-generic", "data/hello.bin", content, "application/octet-stream")
resp, body := doRequest(t, http.MethodGet, api("/api/v1/local/local-generic/data/hello.bin"), nil, "")
if resp.StatusCode != http.StatusOK {
t.Fatalf("download: status %d: %s", resp.StatusCode, body)
}
if !bytes.Equal(body, content) {
t.Fatalf("downloaded content mismatch")
}
}
// TestLocalPyPIUpload uploads a wheel and validates the generated simple index.
func TestLocalPyPIUpload(t *testing.T) {
createRepo(t, `{"name":"local-pypi","package_type":"pypi","repo_type":"local"}`)
defer deleteRepo(t, "local-pypi")
wheel := fixtureBytes(t, "packages/foo-1.0-py3-none-any.whl")
uploadFile(t, "local-pypi", "foo-1.0-py3-none-any.whl", wheel, "application/zip")
// Root index lists the package.
resp, body := doRequest(t, http.MethodGet, api("/api/v1/local/local-pypi/simple/"), nil, "")
if resp.StatusCode != http.StatusOK {
t.Fatalf("simple index: status %d: %s", resp.StatusCode, body)
}
if !strings.Contains(string(body), "foo") {
t.Fatalf("simple index missing package 'foo': %s", body)
}
// Per-package index lists the wheel file.
resp, body = doRequest(t, http.MethodGet, api("/api/v1/local/local-pypi/simple/foo/"), nil, "")
if resp.StatusCode != http.StatusOK {
t.Fatalf("package index: status %d: %s", resp.StatusCode, body)
}
if !strings.Contains(string(body), "foo-1.0-py3-none-any.whl") {
t.Fatalf("package index missing wheel: %s", body)
}
// The wheel downloads back byte-identical.
resp, body = doRequest(t, http.MethodGet, api("/api/v1/local/local-pypi/foo/foo-1.0-py3-none-any.whl"), nil, "")
if resp.StatusCode != http.StatusOK {
t.Fatalf("download wheel: status %d: %s", resp.StatusCode, body)
}
if !bytes.Equal(body, wheel) {
t.Fatalf("wheel content mismatch")
}
}
// TestLocalRPMRepodata uploads a real RPM and validates that repodata is
// generated automatically (the special rpm-local feature).
func TestLocalRPMRepodata(t *testing.T) {
createRepo(t, `{"name":"local-rpm","package_type":"rpm","repo_type":"local"}`)
defer deleteRepo(t, "local-rpm")
rpm := fixtureBytes(t, "rpmrepo/Packages/e2e-testpkg-1.0-1.noarch.rpm")
uploadFile(t, "local-rpm", "e2e-testpkg-1.0-1.noarch.rpm", rpm, "application/x-rpm")
// repodata is generated asynchronously after upload; poll for it.
resp, body := getEventually(t, api("/api/v1/local/local-rpm/repodata/repomd.xml"), 15*time.Second)
if resp.StatusCode != http.StatusOK {
t.Fatalf("repomd.xml: status %d: %s", resp.StatusCode, body)
}
s := string(body)
if !strings.Contains(s, "<repomd") || !strings.Contains(s, "primary") {
t.Fatalf("repomd.xml not a valid repodata document: %s", s)
}
}
-134
View File
@@ -1,134 +0,0 @@
//go:build dockere2e
package e2edocker
import (
"encoding/json"
"net/http"
"testing"
)
func TestHealth(t *testing.T) {
resp, body := doRequest(t, http.MethodGet, api("/health"), nil, "")
if resp.StatusCode != http.StatusOK {
t.Fatalf("health: status %d: %s", resp.StatusCode, body)
}
}
// TestRemoteLifecycle covers add/change/delete for a remote repository.
func TestRemoteLifecycle(t *testing.T) {
createRepo(t, `{
"name": "crud-remote",
"package_type": "generic",
"repo_type": "remote",
"base_url": "https://example.com",
"mutable_ttl": 600,
"stale_on_error": true
}`)
defer deleteRepo(t, "crud-remote")
got := getRepo(t, "crud-remote")
if got["base_url"] != "https://example.com" || got["mutable_ttl"].(float64) != 600 {
t.Fatalf("unexpected created remote: %v", got)
}
// change
resp, body := doRequest(t, http.MethodPut, api("/api/v2/remotes/crud-remote"), []byte(`{
"package_type": "generic",
"base_url": "https://updated.example.com",
"mutable_ttl": 120,
"stale_on_error": true
}`), "application/json")
if resp.StatusCode != http.StatusOK {
t.Fatalf("update remote: status %d: %s", resp.StatusCode, body)
}
got = getRepo(t, "crud-remote")
if got["base_url"] != "https://updated.example.com" || got["mutable_ttl"].(float64) != 120 {
t.Fatalf("update not applied: %v", got)
}
// delete
resp, _ = doRequest(t, http.MethodDelete, api("/api/v2/remotes/crud-remote"), nil, "")
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("delete remote: status %d", resp.StatusCode)
}
resp, _ = doRequest(t, http.MethodGet, api("/api/v2/remotes/crud-remote"), nil, "")
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("expected 404 after delete, got %d", resp.StatusCode)
}
}
// TestLocalLifecycle covers add/delete for a local repository.
func TestLocalLifecycle(t *testing.T) {
createRepo(t, `{
"name": "crud-local",
"package_type": "generic",
"repo_type": "local"
}`)
defer deleteRepo(t, "crud-local")
got := getRepo(t, "crud-local")
if got["repo_type"] != "local" {
t.Fatalf("expected repo_type local, got %v", got["repo_type"])
}
resp, _ := doRequest(t, http.MethodDelete, api("/api/v2/remotes/crud-local"), nil, "")
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("delete local: status %d", resp.StatusCode)
}
}
// TestVirtualLifecycle covers add/change/delete for a virtual repository.
func TestVirtualLifecycle(t *testing.T) {
createRepo(t, `{"name":"vmem-a","package_type":"helm","repo_type":"remote","base_url":"https://a.example.com","stale_on_error":true}`)
createRepo(t, `{"name":"vmem-b","package_type":"helm","repo_type":"remote","base_url":"https://b.example.com","stale_on_error":true}`)
defer deleteRepo(t, "vmem-a")
defer deleteRepo(t, "vmem-b")
createVirtual(t, `{
"name": "crud-virtual",
"package_type": "helm",
"members": ["vmem-a"]
}`)
defer deleteVirtual(t, "crud-virtual")
// change members
resp, body := doRequest(t, http.MethodPut, api("/api/v2/virtuals/crud-virtual"), []byte(`{
"package_type": "helm",
"members": ["vmem-a", "vmem-b"]
}`), "application/json")
if resp.StatusCode != http.StatusOK {
t.Fatalf("update virtual: status %d: %s", resp.StatusCode, body)
}
resp, body = doRequest(t, http.MethodGet, api("/api/v2/virtuals/crud-virtual"), nil, "")
if resp.StatusCode != http.StatusOK {
t.Fatalf("get virtual: status %d: %s", resp.StatusCode, body)
}
var v map[string]any
if err := json.Unmarshal(body, &v); err != nil {
t.Fatalf("decode virtual: %v", err)
}
members, _ := v["members"].([]any)
if len(members) != 2 {
t.Fatalf("expected 2 members after update, got %v", v["members"])
}
resp, _ = doRequest(t, http.MethodDelete, api("/api/v2/virtuals/crud-virtual"), nil, "")
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("delete virtual: status %d", resp.StatusCode)
}
}
func getRepo(t *testing.T, name string) map[string]any {
t.Helper()
resp, body := doRequest(t, http.MethodGet, api("/api/v2/remotes/"+name), nil, "")
if resp.StatusCode != http.StatusOK {
t.Fatalf("get remote %s: status %d: %s", name, resp.StatusCode, body)
}
var m map[string]any
if err := json.Unmarshal(body, &m); err != nil {
t.Fatalf("decode remote %s: %v", name, err)
}
return m
}
-54
View File
@@ -1,54 +0,0 @@
//go:build dockere2e
package e2edocker
import (
"net/http"
"strings"
"testing"
)
// TestVirtualPyPIMerge uploads different packages to two pypi locals and
// checks that a virtual over them serves a merged simple index.
func TestVirtualPyPIMerge(t *testing.T) {
createRepo(t, `{"name":"pmerge-a","package_type":"pypi","repo_type":"local"}`)
createRepo(t, `{"name":"pmerge-b","package_type":"pypi","repo_type":"local"}`)
defer deleteRepo(t, "pmerge-a")
defer deleteRepo(t, "pmerge-b")
uploadFile(t, "pmerge-a", "foo-1.0-py3-none-any.whl", fixtureBytes(t, "packages/foo-1.0-py3-none-any.whl"), "application/zip")
uploadFile(t, "pmerge-b", "bar-2.0-py3-none-any.whl", []byte("bar wheel payload"), "application/zip")
createVirtual(t, `{"name":"pmerge-v","package_type":"pypi","members":["pmerge-a","pmerge-b"]}`)
defer deleteVirtual(t, "pmerge-v")
resp, body := doRequest(t, http.MethodGet, api("/api/v1/virtual/pmerge-v/simple/"), nil, "")
if resp.StatusCode != http.StatusOK {
t.Fatalf("virtual simple index: status %d: %s", resp.StatusCode, body)
}
s := string(body)
if !strings.Contains(s, "foo") || !strings.Contains(s, "bar") {
t.Fatalf("merged index missing a member package (want foo and bar): %s", s)
}
}
// TestVirtualHelmMerge points two helm remotes at mock index.yaml documents
// with distinct charts and checks the virtual merges both into one index.
func TestVirtualHelmMerge(t *testing.T) {
createRepo(t, `{"name":"hmerge-a","package_type":"helm","repo_type":"remote","base_url":"`+mockUpstream()+`/helm-a","stale_on_error":true}`)
createRepo(t, `{"name":"hmerge-b","package_type":"helm","repo_type":"remote","base_url":"`+mockUpstream()+`/helm-b","stale_on_error":true}`)
defer deleteRepo(t, "hmerge-a")
defer deleteRepo(t, "hmerge-b")
createVirtual(t, `{"name":"hmerge-v","package_type":"helm","members":["hmerge-a","hmerge-b"]}`)
defer deleteVirtual(t, "hmerge-v")
resp, body := doRequest(t, http.MethodGet, api("/api/v1/virtual/hmerge-v/index.yaml"), nil, "")
if resp.StatusCode != http.StatusOK {
t.Fatalf("virtual index.yaml: status %d: %s", resp.StatusCode, body)
}
s := string(body)
if !strings.Contains(s, "alpha") || !strings.Contains(s, "beta") {
t.Fatalf("merged helm index missing a member chart (want alpha and beta): %s", s)
}
}
+2 -2
View File
@@ -7,14 +7,12 @@ 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
)
@@ -47,6 +45,7 @@ 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
@@ -97,6 +96,7 @@ 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
@@ -1,301 +0,0 @@
// 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
@@ -1,186 +0,0 @@
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
@@ -1,473 +0,0 @@
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
@@ -1,50 +0,0 @@
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")
}
}
+3 -21
View File
@@ -23,20 +23,10 @@ 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,
cas: storage.NewCAS(store),
uploads: newUploadStore(),
}
return &ProxyHandler{engine: engine, virtualEngine: virtualEngine, db: db, store: store, local: local}
}
func (h *ProxyHandler) Routes() chi.Router {
@@ -47,20 +37,12 @@ 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.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)
r.Get("/{remoteName}/*", h.handleProxy)
r.Head("/{remoteName}/*", h.handleProxyHead)
return r
}
+1 -23
View File
@@ -185,35 +185,13 @@ func (h *LocalHandler) remove(w http.ResponseWriter, r *http.Request) {
repoName := chi.URLParam(r, "name")
filePath := chi.URLParam(r, "*")
if err := deleteLocalFile(r.Context(), h.db, repoName, filePath); err != nil {
if err := h.db.DeleteLocalFile(r.Context(), 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
}
@@ -1,75 +0,0 @@
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)
}
}
-78
View File
@@ -1,78 +0,0 @@
package v2
import (
"context"
"encoding/json"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"git.unkin.net/unkin/artifactapi/internal/database"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
// TestLocalObjectsListing verifies that files uploaded to a local repo (which
// live in local_files, not artifacts) are listed by the local objects endpoint
// and can be evicted through it.
func TestLocalObjectsListing(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-local-objs"
if err := db.CreateRemote(ctx, &models.Remote{Name: repo, PackageType: models.PackageRPM, RepoType: models.RepoTypeLocal}); err != nil {
t.Fatal(err)
}
const hash = "sha256:aa11"
const path = "Packages/example-0.1.0-1.x86_64.rpm"
if err := db.UpsertBlob(ctx, hash, "blobs/aa/11", 1234, "application/x-rpm"); err != nil {
t.Fatal(err)
}
if err := db.CreateLocalFile(ctx, repo, path, hash); err != nil {
t.Fatal(err)
}
h := NewObjectsHandler(db)
router := chi.NewRouter()
router.Route("/locals/{name}/objects", func(r chi.Router) {
r.Get("/", h.LocalRoutes().ServeHTTP)
r.Delete("/*", h.LocalRoutes().ServeHTTP)
})
// The uploaded package must appear in the listing with its blob size.
req := httptest.NewRequest("GET", "/locals/"+repo+"/objects", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("list = %d, want 200", w.Code)
}
var got []models.Artifact
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v", err)
}
if len(got) != 1 {
t.Fatalf("got %d objects, want 1", len(got))
}
if got[0].Path != path || got[0].SizeBytes != 1234 || got[0].ContentHash != hash {
t.Fatalf("unexpected object: %+v", got[0])
}
// Eviction removes it from local_files.
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("file still present after evict: %+v", f)
}
}
+4 -41
View File
@@ -25,18 +25,9 @@ func (h *ObjectsHandler) Routes() chi.Router {
return r
}
// LocalRoutes lists and evicts objects for local repos, which live in the
// local_files table rather than the artifacts table used by remotes.
func (h *ObjectsHandler) LocalRoutes() chi.Router {
r := chi.NewRouter()
r.Get("/", h.listLocal)
r.Delete("/*", h.evictLocal)
return r
}
// pageBounds parses the shared page/per_page query params into a SQL limit and offset.
func pageBounds(r *http.Request) (limit, offset int) {
limit, _ = strconv.Atoi(r.URL.Query().Get("per_page"))
func (h *ObjectsHandler) list(w http.ResponseWriter, r *http.Request) {
remoteName := chi.URLParam(r, "name")
limit, _ := strconv.Atoi(r.URL.Query().Get("per_page"))
if limit <= 0 || limit > 5000 {
limit = 50
}
@@ -44,12 +35,7 @@ func pageBounds(r *http.Request) (limit, offset int) {
if page <= 0 {
page = 1
}
return limit, (page - 1) * limit
}
func (h *ObjectsHandler) list(w http.ResponseWriter, r *http.Request) {
remoteName := chi.URLParam(r, "name")
limit, offset := pageBounds(r)
offset := (page - 1) * limit
artifacts, err := h.db.ListArtifacts(r.Context(), remoteName, limit, offset)
if err != nil {
@@ -59,29 +45,6 @@ func (h *ObjectsHandler) list(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, artifacts)
}
func (h *ObjectsHandler) listLocal(w http.ResponseWriter, r *http.Request) {
repoName := chi.URLParam(r, "name")
limit, offset := pageBounds(r)
artifacts, err := h.db.ListLocalArtifacts(r.Context(), repoName, limit, offset)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, artifacts)
}
func (h *ObjectsHandler) evictLocal(w http.ResponseWriter, r *http.Request) {
repoName := chi.URLParam(r, "name")
path := chi.URLParam(r, "*")
if err := deleteLocalFile(r.Context(), h.db, repoName, path); err != nil {
http.Error(w, fmt.Sprintf("evict failed: %v", err), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *ObjectsHandler) evict(w http.ResponseWriter, r *http.Request) {
remoteName := chi.URLParam(r, "name")
path := chi.URLParam(r, "*")
-12
View File
@@ -24,14 +24,6 @@ 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 {
@@ -67,10 +59,6 @@ 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
-49
View File
@@ -10,7 +10,6 @@ import (
"github.com/jackc/pgx/v5/pgconn"
"git.unkin.net/unkin/artifactapi/internal/provider"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
type LocalFile struct {
@@ -38,20 +37,6 @@ 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
@@ -93,40 +78,6 @@ func (db *DB) ListLocalFiles(ctx context.Context, repoName string, limit, offset
return files, rows.Err()
}
// ListLocalArtifacts returns a repo's local files shaped as models.Artifact so
// the UI's cached-objects view can render them the same way as remote artifacts.
// Local files carry no access/fetch counters, so those are left at zero and the
// timestamps are all derived from created_at.
func (db *DB) ListLocalArtifacts(ctx context.Context, repoName string, limit, offset int) ([]models.Artifact, error) {
rows, err := db.Pool.Query(ctx, `
SELECT lf.id, lf.repo_name, lf.file_path, lf.content_hash,
lf.created_at, b.size_bytes, b.content_type
FROM local_files lf
JOIN blobs b ON lf.content_hash = b.content_hash
WHERE lf.repo_name = $1
ORDER BY lf.file_path
LIMIT $2 OFFSET $3
`, repoName, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var artifacts []models.Artifact
for rows.Next() {
var a models.Artifact
var createdAt time.Time
if err := rows.Scan(&a.ID, &a.RemoteName, &a.Path, &a.ContentHash, &createdAt, &a.SizeBytes, &a.ContentType); err != nil {
return nil, err
}
a.FirstSeenAt = createdAt
a.LastFetchedAt = createdAt
a.LastAccessedAt = createdAt
artifacts = append(artifacts, a)
}
return artifacts, rows.Err()
}
func (db *DB) ListLocalFilesByPrefix(ctx context.Context, repoName, prefix string) ([]LocalFile, error) {
rows, err := db.Pool.Query(ctx, `
SELECT id, repo_name, file_path, content_hash, created_at
-7
View File
@@ -158,13 +158,6 @@ 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,11 +32,6 @@ 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
@@ -1,35 +0,0 @@
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
@@ -1,31 +0,0 @@
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,20 +53,10 @@ 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,15 +151,6 @@ 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 {
+7 -2
View File
@@ -7,14 +7,16 @@ import (
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"git.unkin.net/unkin/artifactapi/internal/provider"
"git.unkin.net/unkin/artifactapi/internal/testsupport"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
const fixtureRPM = "testdata/e2e-testpkg-1.0-1.noarch.rpm"
type fakeBlobReader struct{ data []byte }
func (f fakeBlobReader) Download(_ context.Context, _ string) (io.ReadCloser, int64, error) {
@@ -80,7 +82,10 @@ func TestRPMValidateUpload(t *testing.T) {
}
func TestRPMAfterUpload(t *testing.T) {
data := testsupport.MinimalRPM("e2e-testpkg", "1.0", "1", "noarch")
data, err := os.ReadFile(fixtureRPM)
if err != nil {
t.Fatalf("fixture: %v", err)
}
store := &fakeMetaStore{}
(&Provider{}).AfterUpload(context.Background(), "myrepo", "Packages/e2e-testpkg-1.0-1.noarch.rpm",
"sha256:deadbeef", fakeBlobReader{data: data}, store)
-21
View File
@@ -26,27 +26,6 @@ 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 }
@@ -1,171 +0,0 @@
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")
}
}
-41
View File
@@ -12,7 +12,6 @@ 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"
@@ -31,7 +30,6 @@ 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"
)
@@ -45,7 +43,6 @@ type Server struct {
engine *proxy.Engine
virtEngine *virtual.Engine
localHandler *v2.LocalHandler
tfRegistry *tfregistry.Handler
gc *gc.Collector
}
@@ -70,25 +67,6 @@ 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,
@@ -98,7 +76,6 @@ func New(cfg *config.Config, version string) (*Server, error) {
engine: engine,
virtEngine: virtEngine,
localHandler: localHandler,
tfRegistry: tfRegistry,
gc: collector,
}
@@ -118,12 +95,6 @@ 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())
@@ -150,12 +121,6 @@ func (s *Server) routes() chi.Router {
r.Delete("/*", objHandler.Routes().ServeHTTP)
})
r.Route("/locals/{name}/objects", func(r chi.Router) {
objHandler := v2.NewObjectsHandler(s.db)
r.Get("/", objHandler.LocalRoutes().ServeHTTP)
r.Delete("/*", objHandler.LocalRoutes().ServeHTTP)
})
r.Route("/remotes/{name}/files", func(r chi.Router) {
r.Put("/*", s.localHandler.Routes().ServeHTTP)
r.Get("/*", s.localHandler.Routes().ServeHTTP)
@@ -172,13 +137,7 @@ 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)
+6 -22
View File
@@ -129,32 +129,13 @@ 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 := 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, b := req(t, "GET", "/", ""); resp.StatusCode != 200 || !strings.Contains(string(b), "test-version") {
t.Errorf("root: %d %s", resp.StatusCode, b)
}
if resp, _ := req(t, "GET", "/api/v2/health", ""); resp.StatusCode != 200 {
t.Errorf("health v2: %d", resp.StatusCode)
@@ -320,7 +301,10 @@ func TestServerLocalPyPI(t *testing.T) {
func TestServerLocalRPMRepodata(t *testing.T) {
requireStack(t)
rpm := testsupport.MinimalRPM("e2e-testpkg", "1.0", "1", "noarch")
rpm, err := os.ReadFile("../provider/rpm/testdata/e2e-testpkg-1.0-1.noarch.rpm")
if err != nil {
t.Skipf("rpm fixture unavailable: %v", err)
}
if resp, b := req(t, "POST", "/api/v2/remotes", `{"name":"srv-rpm","package_type":"rpm","repo_type":"local"}`); resp.StatusCode != 201 {
t.Fatalf("create rpm local: %d %s", resp.StatusCode, b)
}
-59
View File
@@ -1,59 +0,0 @@
package testsupport
import (
"bytes"
"encoding/binary"
)
// MinimalRPM builds a valid-enough RPM package in pure Go (no committed binary
// fixture, no external rpmbuild). It carries just the header tags the provider
// reads: name/version/release/arch plus a single self Provides entry, which is
// enough for cavaliergopher/rpm to parse and for repodata generation.
func MinimalRPM(name, version, release, arch string) []byte {
type tag struct {
id, typ, count uint32
data []byte
}
cstr := func(s string) []byte { return append([]byte(s), 0) }
tags := []tag{
{1000, 6, 1, cstr(name)}, // RPMTAG_NAME (STRING)
{1001, 6, 1, cstr(version)}, // RPMTAG_VERSION
{1002, 6, 1, cstr(release)}, // RPMTAG_RELEASE
{1022, 6, 1, cstr(arch)}, // RPMTAG_ARCH
{1047, 8, 1, cstr(name)}, // RPMTAG_PROVIDENAME (STRING_ARRAY)
{1112, 4, 1, []byte{0, 0, 0, 0}}, // RPMTAG_PROVIDEFLAGS (INT32)
{1113, 8, 1, cstr(version)}, // RPMTAG_PROVIDEVERSION (STRING_ARRAY)
}
buildHeader := func(entries []tag) []byte {
var index, store bytes.Buffer
for _, e := range entries {
off := uint32(store.Len())
for _, v := range []uint32{e.id, e.typ, off, e.count} {
binary.Write(&index, binary.BigEndian, v)
}
store.Write(e.data)
}
var b bytes.Buffer
b.Write([]byte{0x8e, 0xad, 0xe8, 0x01, 0, 0, 0, 0}) // header magic + reserved
binary.Write(&b, binary.BigEndian, uint32(len(entries)))
binary.Write(&b, binary.BigEndian, uint32(store.Len()))
b.Write(index.Bytes())
b.Write(store.Bytes())
return b.Bytes()
}
lead := make([]byte, 96)
copy(lead[0:4], []byte{0xed, 0xab, 0xee, 0xdb}) // lead magic
lead[4] = 3 // major version
binary.BigEndian.PutUint16(lead[8:10], 1) // archnum
copy(lead[10:76], name) // name (66 bytes, null-padded)
binary.BigEndian.PutUint16(lead[76:78], 1) // osnum
binary.BigEndian.PutUint16(lead[78:80], 5) // signature type
var out bytes.Buffer
out.Write(lead)
out.Write(buildHeader(nil)) // empty signature header (16 bytes, 8-aligned)
out.Write(buildHeader(tags))
return out.Bytes()
}
-173
View File
@@ -1,173 +0,0 @@
// 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
@@ -1,155 +0,0 @@
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")
}
}
-40
View File
@@ -1,40 +0,0 @@
#!/usr/bin/env bash
# Build the artifactapi container, bring up the full stack (postgres, redis,
# minio, artifactapi) plus a static mock upstream, and run the dockerised e2e
# suite against the running product over HTTP. Tears everything down on exit.
set -euo pipefail
cd "$(dirname "$0")/.."
# Publish artifactapi on 8001 to avoid colliding with a local instance on 8000.
export ARTIFACTAPI_PORT="${ARTIFACTAPI_PORT:-8001}"
COMPOSE=(docker compose -f docker-compose.yml -f docker-compose.e2e.yml)
API_URL="${ARTIFACTAPI_URL:-http://localhost:${ARTIFACTAPI_PORT}}"
cleanup() {
echo "==> tearing down stack"
"${COMPOSE[@]}" down -v --remove-orphans >/dev/null 2>&1 || true
}
trap cleanup EXIT
echo "==> building and starting stack (postgres, redis, minio, mockupstream, artifactapi)"
"${COMPOSE[@]}" up -d --build postgres redis minio mockupstream artifactapi
echo "==> waiting for artifactapi health at ${API_URL}"
for i in $(seq 1 60); do
if curl -fsS "${API_URL}/health" >/dev/null 2>&1; then
echo " healthy after ${i}s"
break
fi
if [ "$i" -eq 60 ]; then
echo "!!! artifactapi did not become healthy in time; recent logs:"
"${COMPOSE[@]}" logs --tail=50 artifactapi
exit 1
fi
sleep 1
done
echo "==> running dockerised e2e suite"
ARTIFACTAPI_URL="${API_URL}" \
MOCK_UPSTREAM_INTERNAL="${MOCK_UPSTREAM_INTERNAL:-http://mockupstream}" \
go test -tags=dockere2e -count=1 -timeout=10m -v ./e2e-docker/...
-6
View File
@@ -34,12 +34,6 @@ export const api = {
evictObject: (remote: string, path: string) =>
fetchJSON<void>(`/api/v2/remotes/${remote}/objects/${path}`, { method: 'DELETE' }),
listLocalObjects: (name: string, page = 1, perPage = 50) =>
fetchJSON<Artifact[]>(`/api/v2/locals/${name}/objects?page=${page}&per_page=${perPage}`),
evictLocalObject: (name: string, path: string) =>
fetchJSON<void>(`/api/v2/locals/${name}/objects/${path}`, { method: 'DELETE' }),
flushRemoteCache: (remote: string) =>
fetchJSON<void>(`/api/v2/remotes/${remote}/cache`, { method: 'DELETE' }),
+3 -4
View File
@@ -182,17 +182,16 @@ export function Objects() {
const load = useCallback(() => {
if (!name) return;
setLoading(true);
const req = isLocal ? api.listLocalObjects(name, 1, 5000) : api.listObjects(name, 1, 5000);
req
api.listObjects(name, 1, 5000)
.then(a => setArtifacts(a || []))
.finally(() => setLoading(false));
}, [name, isLocal]);
}, [name]);
useEffect(() => { load(); }, [load]);
const handleEvict = async (path: string) => {
if (!name || !confirm(`Evict ${path}?`)) return;
await (isLocal ? api.evictLocalObject(name, path) : api.evictObject(name, path));
await api.evictObject(name, path);
load();
};