Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c47daca1f1 |
@@ -1,6 +1,2 @@
|
|||||||
bin/
|
bin/
|
||||||
terraform/
|
terraform/
|
||||||
|
|
||||||
# e2e-docker fixtures are real package files (.rpm, .tgz, .whl, .zip, ...) that
|
|
||||||
# are intentionally tracked, overriding any global ignore of those extensions.
|
|
||||||
!e2e-docker/fixtures/**
|
|
||||||
|
|||||||
@@ -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
|
BINARY := bin/artifactapi
|
||||||
MODULE := git.unkin.net/unkin/artifactapi
|
MODULE := git.unkin.net/unkin/artifactapi
|
||||||
@@ -28,11 +28,6 @@ fmt: check-go
|
|||||||
e2e: check-go
|
e2e: check-go
|
||||||
TESTCONTAINERS_RYUK_DISABLED=true go test -tags=e2e -race -count=1 -timeout=5m ./e2e/...
|
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:
|
||||||
docker build -t artifactapi:$(VERSION) .
|
docker build -t artifactapi:$(VERSION) .
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -2,7 +2,7 @@ services:
|
|||||||
artifactapi:
|
artifactapi:
|
||||||
build: .
|
build: .
|
||||||
ports:
|
ports:
|
||||||
- "${ARTIFACTAPI_PORT:-8000}:8000"
|
- "8000:8000"
|
||||||
environment:
|
environment:
|
||||||
LISTEN_ADDR: ":8000"
|
LISTEN_ADDR: ":8000"
|
||||||
DBHOST: postgres
|
DBHOST: postgres
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Binary file not shown.
@@ -1 +0,0 @@
|
|||||||
hello artifactapi generic blob
|
|
||||||
Binary file not shown.
Binary file not shown.
@@ -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"
|
|
||||||
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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,8 +2,6 @@ package proxy
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -33,7 +31,6 @@ type Engine struct {
|
|||||||
cache *cache.Redis
|
cache *cache.Redis
|
||||||
store *storage.S3
|
store *storage.S3
|
||||||
cas *storage.CAS
|
cas *storage.CAS
|
||||||
circuit *CircuitBreaker
|
|
||||||
accessLog chan database.AccessLogEntry
|
accessLog chan database.AccessLogEntry
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +40,6 @@ func NewEngine(db *database.DB, c *cache.Redis, s *storage.S3) *Engine {
|
|||||||
cache: c,
|
cache: c,
|
||||||
store: s,
|
store: s,
|
||||||
cas: storage.NewCAS(s),
|
cas: storage.NewCAS(s),
|
||||||
circuit: NewCircuitBreaker(c),
|
|
||||||
accessLog: make(chan database.AccessLogEntry, accessLogBufferSize),
|
accessLog: make(chan database.AccessLogEntry, accessLogBufferSize),
|
||||||
}
|
}
|
||||||
go e.runAccessLogWriter()
|
go e.runAccessLogWriter()
|
||||||
@@ -158,26 +154,10 @@ func (e *Engine) Fetch(ctx context.Context, remote models.Remote, path string, p
|
|||||||
fwdHeaders = clientHeaders[0]
|
fwdHeaders = clientHeaders[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Short-circuit upstream calls when the remote's breaker is open: serve
|
|
||||||
// stale from the store if we have it, otherwise fail fast rather than
|
|
||||||
// hammering a known-bad upstream.
|
|
||||||
if e.circuit.IsOpen(ctx, remote.Name) {
|
|
||||||
if stale, serr := e.serveFromStore(ctx, remote, path); serr == nil {
|
|
||||||
slog.Warn("circuit open, serving stale", "remote", remote.Name, "path", path)
|
|
||||||
stale.Source = "cache"
|
|
||||||
e.logAccess(remote.Name, path, true, stale.Size, 0)
|
|
||||||
return stale, nil
|
|
||||||
}
|
|
||||||
return nil, &ProxyError{Status: http.StatusServiceUnavailable, Message: "upstream circuit open"}
|
|
||||||
}
|
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
result, err := e.fetchFromUpstream(ctx, remote, path, prov, class, ttl, fwdHeaders)
|
result, err := e.fetchFromUpstream(ctx, remote, path, prov, class, ttl, fwdHeaders)
|
||||||
upstreamMS := int(time.Since(start).Milliseconds())
|
upstreamMS := int(time.Since(start).Milliseconds())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if isNetworkError(err) {
|
|
||||||
e.circuit.RecordFailure(ctx, remote.Name)
|
|
||||||
}
|
|
||||||
if remote.StaleOnError && isNetworkError(err) {
|
if remote.StaleOnError && isNetworkError(err) {
|
||||||
_ = e.cache.SetTTL(ctx, remote.Name, path, ttl)
|
_ = e.cache.SetTTL(ctx, remote.Name, path, ttl)
|
||||||
stale, serr := e.serveFromStore(ctx, remote, path)
|
stale, serr := e.serveFromStore(ctx, remote, path)
|
||||||
@@ -191,7 +171,6 @@ func (e *Engine) Fetch(ctx context.Context, remote models.Remote, path string, p
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
e.circuit.RecordSuccess(ctx, remote.Name)
|
|
||||||
e.logAccess(remote.Name, path, false, result.Size, upstreamMS)
|
e.logAccess(remote.Name, path, false, result.Size, upstreamMS)
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
@@ -254,7 +233,7 @@ func (e *Engine) headUpstream(ctx context.Context, remote models.Remote, path st
|
|||||||
}
|
}
|
||||||
if resp.StatusCode == http.StatusUnauthorized {
|
if resp.StatusCode == http.StatusUnauthorized {
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
token, _, terr := fetchBearerToken(ctx, resp.Header.Get("Www-Authenticate"), remote)
|
token, terr := fetchBearerToken(ctx, resp.Header.Get("Www-Authenticate"), remote)
|
||||||
if terr == nil && token != "" {
|
if terr == nil && token != "" {
|
||||||
resp, err = doHead(http.Header{"Authorization": []string{"Bearer " + token}})
|
resp, err = doHead(http.Header{"Authorization": []string{"Bearer " + token}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -535,11 +514,6 @@ const (
|
|||||||
bearerTokenTTLMargin = 10 * time.Second
|
bearerTokenTTLMargin = 10 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
func sha256Hash(data []byte) string {
|
|
||||||
h := sha256.Sum256(data)
|
|
||||||
return hex.EncodeToString(h[:])
|
|
||||||
}
|
|
||||||
|
|
||||||
// cachedBearerToken returns a bearer token for the given challenge, reusing a
|
// cachedBearerToken returns a bearer token for the given challenge, reusing a
|
||||||
// Redis-cached token for the same remote+challenge while it is still valid.
|
// Redis-cached token for the same remote+challenge while it is still valid.
|
||||||
func (e *Engine) cachedBearerToken(ctx context.Context, wwwAuth string, remote models.Remote) (string, error) {
|
func (e *Engine) cachedBearerToken(ctx context.Context, wwwAuth string, remote models.Remote) (string, error) {
|
||||||
|
|||||||
@@ -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/...
|
|
||||||
Reference in New Issue
Block a user