diff --git a/.gitignore b/.gitignore index 897030b..472957c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,6 @@ 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/** diff --git a/Makefile b/Makefile index e2d1889..62b99aa 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build test lint fmt e2e docker docker-ui compose clean tidy check-go +.PHONY: build test lint fmt e2e docker-e2e docker docker-ui compose clean tidy check-go BINARY := bin/artifactapi MODULE := git.unkin.net/unkin/artifactapi @@ -28,6 +28,11 @@ 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) . diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml new file mode 100644 index 0000000..8bfa2d6 --- /dev/null +++ b/docker-compose.e2e.yml @@ -0,0 +1,18 @@ +# 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 diff --git a/docker-compose.yml b/docker-compose.yml index 7687e20..0ee2dba 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ services: artifactapi: build: . ports: - - "8000:8000" + - "${ARTIFACTAPI_PORT:-8000}:8000" environment: LISTEN_ADDR: ":8000" DBHOST: postgres diff --git a/e2e-docker/README.md b/e2e-docker/README.md new file mode 100644 index 0000000..3a7773a --- /dev/null +++ b/e2e-docker/README.md @@ -0,0 +1,39 @@ +# 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. diff --git a/e2e-docker/caching_test.go b/e2e-docker/caching_test.go new file mode 100644 index 0000000..8f60307 --- /dev/null +++ b/e2e-docker/caching_test.go @@ -0,0 +1,76 @@ +//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//. 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)) + } + }) + } +} diff --git a/e2e-docker/fixtures/alpine/x86_64/testpkg-1.0-r0.apk b/e2e-docker/fixtures/alpine/x86_64/testpkg-1.0-r0.apk new file mode 100644 index 0000000..9b901a6 Binary files /dev/null and b/e2e-docker/fixtures/alpine/x86_64/testpkg-1.0-r0.apk differ diff --git a/e2e-docker/fixtures/blobs/hello.bin b/e2e-docker/fixtures/blobs/hello.bin new file mode 100644 index 0000000..014592c --- /dev/null +++ b/e2e-docker/fixtures/blobs/hello.bin @@ -0,0 +1 @@ +hello artifactapi generic blob diff --git a/e2e-docker/fixtures/charts/mychart-1.0.0.tgz b/e2e-docker/fixtures/charts/mychart-1.0.0.tgz new file mode 100644 index 0000000..7094432 Binary files /dev/null and b/e2e-docker/fixtures/charts/mychart-1.0.0.tgz differ diff --git a/e2e-docker/fixtures/goproxy/example.com/mod/@v/v1.0.0.zip b/e2e-docker/fixtures/goproxy/example.com/mod/@v/v1.0.0.zip new file mode 100644 index 0000000..2c07b15 Binary files /dev/null and b/e2e-docker/fixtures/goproxy/example.com/mod/@v/v1.0.0.zip differ diff --git a/e2e-docker/fixtures/helm-a/index.yaml b/e2e-docker/fixtures/helm-a/index.yaml new file mode 100644 index 0000000..79c1f28 --- /dev/null +++ b/e2e-docker/fixtures/helm-a/index.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +entries: + alpha: + - name: alpha + version: 1.0.0 + urls: + - charts/alpha-1.0.0.tgz +generated: "2026-01-01T00:00:00Z" diff --git a/e2e-docker/fixtures/helm-b/index.yaml b/e2e-docker/fixtures/helm-b/index.yaml new file mode 100644 index 0000000..7d0c06b --- /dev/null +++ b/e2e-docker/fixtures/helm-b/index.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +entries: + beta: + - name: beta + version: 2.0.0 + urls: + - charts/beta-2.0.0.tgz +generated: "2026-01-01T00:00:00Z" diff --git a/e2e-docker/fixtures/mypkg/-/mypkg-1.0.0.tgz b/e2e-docker/fixtures/mypkg/-/mypkg-1.0.0.tgz new file mode 100644 index 0000000..be1df01 Binary files /dev/null and b/e2e-docker/fixtures/mypkg/-/mypkg-1.0.0.tgz differ diff --git a/e2e-docker/fixtures/packages/foo-1.0-py3-none-any.whl b/e2e-docker/fixtures/packages/foo-1.0-py3-none-any.whl new file mode 100644 index 0000000..ce06330 Binary files /dev/null and b/e2e-docker/fixtures/packages/foo-1.0-py3-none-any.whl differ diff --git a/e2e-docker/fixtures/puppet-releases/author-mod-1.0.0.tar.gz b/e2e-docker/fixtures/puppet-releases/author-mod-1.0.0.tar.gz new file mode 100644 index 0000000..46abd92 Binary files /dev/null and b/e2e-docker/fixtures/puppet-releases/author-mod-1.0.0.tar.gz differ diff --git a/e2e-docker/fixtures/rpmrepo/Packages/e2e-testpkg-1.0-1.noarch.rpm b/e2e-docker/fixtures/rpmrepo/Packages/e2e-testpkg-1.0-1.noarch.rpm new file mode 100644 index 0000000..1977530 Binary files /dev/null and b/e2e-docker/fixtures/rpmrepo/Packages/e2e-testpkg-1.0-1.noarch.rpm differ diff --git a/e2e-docker/fixtures/v1/providers/hashicorp/aws/download/pkg.zip b/e2e-docker/fixtures/v1/providers/hashicorp/aws/download/pkg.zip new file mode 100644 index 0000000..1834c30 Binary files /dev/null and b/e2e-docker/fixtures/v1/providers/hashicorp/aws/download/pkg.zip differ diff --git a/e2e-docker/fixtures/v2/library/testimg/blobs/blobdata b/e2e-docker/fixtures/v2/library/testimg/blobs/blobdata new file mode 100644 index 0000000..ed84624 Binary files /dev/null and b/e2e-docker/fixtures/v2/library/testimg/blobs/blobdata differ diff --git a/e2e-docker/helpers_test.go b/e2e-docker/helpers_test.go new file mode 100644 index 0000000..c9bea92 --- /dev/null +++ b/e2e-docker/helpers_test.go @@ -0,0 +1,108 @@ +//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) + } +} diff --git a/e2e-docker/local_test.go b/e2e-docker/local_test.go new file mode 100644 index 0000000..013faad --- /dev/null +++ b/e2e-docker/local_test.go @@ -0,0 +1,93 @@ +//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, " 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/...