Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9294686ea6 | |||
| fe9de81afa | |||
| 2905ec99e4 | |||
| 7fa5434dbc | |||
| ca269c5046 | |||
| 3da8545464 |
@@ -26,10 +26,10 @@ Classification precedence, TTLs and eviction for every remote: [docs/common/cach
|
||||
|
||||
Each type's paths, Terraform examples and local/virtual behaviour are in `docs/<type>/`:
|
||||
|
||||
- `alpine`: [remote](docs/alpine/remote.md), [remote-github](docs/alpine/remote-github.md)
|
||||
- `alpine`: [remote](docs/alpine/remote.md), [remote-github](docs/alpine/remote-github.md), [virtual](docs/alpine/virtual.md)
|
||||
- `ansible`: [remote](docs/ansible/remote.md), [local](docs/ansible/local.md), [virtual](docs/ansible/virtual.md)
|
||||
- `cargo`: [remote](docs/cargo/remote.md)
|
||||
- `deb`: [remote](docs/deb/remote.md), [remote-github](docs/deb/remote-github.md)
|
||||
- `deb`: [remote](docs/deb/remote.md), [remote-github](docs/deb/remote-github.md), [virtual](docs/deb/virtual.md)
|
||||
- `docker`: [remote](docs/docker/remote.md), [local](docs/docker/local.md)
|
||||
- `generic`: [remote](docs/generic/remote.md)
|
||||
- `gems`: [remote](docs/gems/remote.md), [local](docs/gems/local.md), [virtual](docs/gems/virtual.md)
|
||||
@@ -37,10 +37,11 @@ Each type's paths, Terraform examples and local/virtual behaviour are in `docs/<
|
||||
- `helm`: [remote](docs/helm/remote.md), [local](docs/helm/local.md), [virtual](docs/helm/virtual.md)
|
||||
- `npm`: [remote](docs/npm/remote.md), [local](docs/npm/local.md), [virtual](docs/npm/virtual.md)
|
||||
- `nuget`: [remote](docs/nuget/remote.md)
|
||||
- `puppet`: [remote](docs/puppet/remote.md), [local](docs/puppet/local.md)
|
||||
- `puppet`: [remote](docs/puppet/remote.md), [local](docs/puppet/local.md), [virtual](docs/puppet/virtual.md)
|
||||
- `pypi`: [remote](docs/pypi/remote.md)
|
||||
- `rpm`: [remote](docs/rpm/remote.md), [remote-github](docs/rpm/remote-github.md)
|
||||
- `terraform`: [remote](docs/terraform/remote.md), [local](docs/terraform/local.md)
|
||||
- `vcs`: [remote](docs/vcs/remote.md)
|
||||
|
||||
### GitHub authentication
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# `alpine` virtual
|
||||
|
||||
An alpine virtual serves one apk repository over local and remote alpine
|
||||
members at `/api/v1/virtual/<name>/`.
|
||||
|
||||
- `<dir>/APKINDEX.tar.gz` merges each member's index for the same `<dir>`
|
||||
(e.g. `x86_64/`); on a duplicate (P, V, A) the earlier member wins. A member
|
||||
that 404s for that directory is skipped.
|
||||
- apk builds download paths itself (`<dir>/<P>-<V>.apk`), so the virtual 302s
|
||||
each download to the member whose index listed it.
|
||||
- The merged index is unsigned, like local and `github_alpine` indexes: clients
|
||||
need `--allow-untrusted` (packages keep their own signatures).
|
||||
- Any other member error fails the request with 502. Merges are cached for 60s
|
||||
per directory, up to 32 directories per virtual.
|
||||
|
||||
```hcl
|
||||
resource "artifactapi_virtual" "alpine" {
|
||||
name = "alpine"
|
||||
package_type = "alpine"
|
||||
members = [artifactapi_local_alpine.internal.name, artifactapi_remote_github_alpine.tool.name]
|
||||
}
|
||||
```
|
||||
|
||||
```sh
|
||||
echo https://artifactapi.example.com/api/v1/virtual/alpine >> /etc/apk/repositories
|
||||
apk update --allow-untrusted
|
||||
apk add --allow-untrusted mytool
|
||||
```
|
||||
|
||||
Upload to the local member directly; the virtual is read-only.
|
||||
@@ -98,4 +98,4 @@ Each type's page lists its auto-detected mutable and immutable paths:
|
||||
[docker](../docker/remote.md), [generic](../generic/remote.md), [gems](../gems/remote.md), [goproxy](../goproxy/remote.md),
|
||||
[helm](../helm/remote.md), [npm](../npm/remote.md), [nuget](../nuget/remote.md),
|
||||
[puppet](../puppet/remote.md), [pypi](../pypi/remote.md), [rpm](../rpm/remote.md),
|
||||
[rpm github](../rpm/remote-github.md), [terraform](../terraform/remote.md).
|
||||
[rpm github](../rpm/remote-github.md), [terraform](../terraform/remote.md), [vcs](../vcs/remote.md).
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# `deb` virtual
|
||||
|
||||
A deb virtual serves one apt source over local and remote deb members at
|
||||
`/api/v1/virtual/<name>/`, in either the flat (`./`) or `dists/<suite>` layout.
|
||||
|
||||
- Packages indexes are merged; on a duplicate (Package, Version, Architecture)
|
||||
the earlier member wins.
|
||||
- The Release is generated unsigned with `Acquire-By-Hash: yes`; indexes are
|
||||
served by name and by SHA256 from the current and previous merge.
|
||||
- `Filename` points at the virtual (`<member>/<path>`), which 302s to the
|
||||
owning member.
|
||||
- All members must share one layout; mixing flat and suite members is
|
||||
unsupported (suite requests fail with 502).
|
||||
- A member error fails the request with 502. Merges are cached for 60s per
|
||||
suite, up to 32 suites per virtual.
|
||||
|
||||
```hcl
|
||||
resource "artifactapi_virtual" "deb" {
|
||||
name = "deb"
|
||||
package_type = "deb"
|
||||
members = [artifactapi_local_deb.internal.name, artifactapi_remote_github_deb.tool.name]
|
||||
}
|
||||
```
|
||||
|
||||
```
|
||||
# /etc/apt/sources.list.d/artifactapi.list (flat)
|
||||
deb [trusted=yes] https://artifactapi.example.com/api/v1/virtual/deb/ ./
|
||||
# suite layout
|
||||
deb [trusted=yes] https://artifactapi.example.com/api/v1/virtual/debian/ trixie main
|
||||
```
|
||||
|
||||
Upload to the local member directly; the virtual is read-only.
|
||||
@@ -5,3 +5,6 @@ Shared classification, TTL and eviction rules: [caching](../common/caching.md).
|
||||
| Type | Mutable (auto-detected) | Immutable (auto-detected) |
|
||||
|---|---|---|
|
||||
| `puppet` | `v3/modules/*`, `v3/releases*` | `.tar.gz` |
|
||||
|
||||
`/v3/releases` forwards the `module`, `limit` and `offset` query params
|
||||
upstream; set `base_url` to the Forge API root, e.g. `https://forgeapi.puppet.com`.
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# `puppet` virtual
|
||||
|
||||
A puppet virtual serves one Forge v3 API over local and remote puppet members:
|
||||
`puppet module install` and r10k work against `/api/v1/virtual/<name>`, with
|
||||
dependencies resolved across members.
|
||||
|
||||
- `/v3/releases?module=<slug>` merges member releases; on a duplicate version
|
||||
the earlier member wins. Releases are newest first, paginated with
|
||||
`limit` (up to 100) and `offset`; pagination links and `file_uri` are
|
||||
relative to the Forge root, so downloads route via the virtual.
|
||||
- `/v3/modules/<slug>` lists the merged releases with the highest version as
|
||||
`current_release`.
|
||||
- `/v3/releases/<slug>-<version>` and `/v3/files/<file>` 302 to the member
|
||||
owning that release.
|
||||
- A member error fails the request with 502; a member without the module is
|
||||
skipped. Merges, and modules no member has, are cached for 60s.
|
||||
|
||||
```hcl
|
||||
resource "artifactapi_virtual" "puppet" {
|
||||
name = "puppet"
|
||||
package_type = "puppet"
|
||||
members = [artifactapi_local_puppet.internal.name, artifactapi_remote_puppet.forge.name]
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
puppet module install unkin-hello \
|
||||
--module_repository https://artifactapi.example.com/api/v1/virtual/puppet
|
||||
```
|
||||
|
||||
```yaml
|
||||
# r10k.yaml
|
||||
forge:
|
||||
baseurl: https://artifactapi.example.com/api/v1/virtual/puppet
|
||||
```
|
||||
|
||||
Upload to the local member directly; the virtual is read-only.
|
||||
@@ -0,0 +1,29 @@
|
||||
# `vcs` remote
|
||||
|
||||
Shared classification, TTL and eviction rules: [caching](../common/caching.md).
|
||||
|
||||
Caches source archives and release assets from GitHub (`base_url = "https://github.com"`) or Gitea (any other `base_url`). GitHub Enterprise is not supported: a non-github.com host is treated as Gitea. Only the paths below are served; anything else is a 404.
|
||||
|
||||
| Path | Behaviour |
|
||||
|---|---|
|
||||
| `<owner>/<repo>/archive/refs/tags/<tag>.(tar.gz\|zip)` | immutable |
|
||||
| `<owner>/<repo>/archive/<40-char sha>.(tar.gz\|zip)` | immutable |
|
||||
| `<owner>/<repo>/releases/download/<tag>/<asset>` | immutable |
|
||||
| `<owner>/<repo>/releases/latest/download/<asset>` | mutable |
|
||||
| `<owner>/<repo>/archive/refs/heads/<branch>.(tar.gz\|zip)` | `302` to the archive of the branch's current commit, resolved via the provider's branch API and reused for `mutable_ttl` |
|
||||
|
||||
Branch archives redirect, so `curl` needs `-L`; pip and npm follow redirects. The archive is the host's commit archive (GitHub names its top-level directory `<repo>-<sha>`). `patterns` and `blocklist` match both the branch path and the commit path it redirects to, so allowlist repos by prefix (`^cli/cli/`); a branch whose commit path is denied is a `403`. A missing branch is a `404`, cached for `mutable_ttl` (at least 60s). An API rate limit (`429`, or `403` with rate-limit headers) is a `503` with `Retry-After`, unless `stale_on_error` serves the last known commit. Each remote caches up to 4096 branch lookups. GitHub API calls use the [server GitHub credential](../../README.md#github-authentication); Gitea API calls use the remote's `username`/`password`.
|
||||
|
||||
```hcl
|
||||
resource "artifactapi_remote_vcs" "github" {
|
||||
name = "github-vcs"
|
||||
base_url = "https://github.com"
|
||||
mutable_ttl = 600
|
||||
patterns = ["^cli/cli/", "^psf/requests/"]
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
curl -fsSLO https://artifactapi.example/api/v1/remote/github-vcs/cli/cli/archive/refs/tags/v2.60.0.tar.gz
|
||||
pip install https://artifactapi.example/api/v1/remote/github-vcs/psf/requests/archive/refs/tags/v2.32.3.tar.gz
|
||||
```
|
||||
@@ -40,7 +40,9 @@ already-running stack.
|
||||
downloads 302 to the owning member); ansible over two locals via stock
|
||||
`ansible-galaxy` (install through the virtual, download 302 to the owner);
|
||||
goproxy over two locals via a stock `golang:1.25` client (merged versions,
|
||||
member order wins, version files 302 to the owning member).
|
||||
member order wins, version files 302 to the owning member); puppet over two
|
||||
locals via real `puppet module install` (dependency resolved across members)
|
||||
and `r10k`, downloads 302 to the owning member.
|
||||
- **Mirrorlist** — an rpm remote with a `mirrorlist` of extra upstream mirrors
|
||||
(pool = `base_url` + `mirrorlist`): round-robin distribution across both mirrors
|
||||
(constant-body `mockupstreama` / `mockupstreamb`), failover past a dead primary,
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
//go:build dockere2e
|
||||
|
||||
package e2edocker
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// abuildScript builds a real, abuild-signed $N-$V-r0.apk and writes it to stdout.
|
||||
const abuildScript = `set -eu
|
||||
apk add -q abuild >&2
|
||||
adduser -D b && addgroup b abuild
|
||||
su b -c 'set -eu; abuild-keygen -an >&2; mkdir -p ~/p && cd ~/p
|
||||
printf "%s\n" "pkgname=$N" "pkgver=$V" pkgrel=0 "pkgdesc=\"e2e\"" "url=\"https://example.com\"" "arch=\"x86_64\"" "license=\"MIT\"" "options=\"!check\"" \
|
||||
"package() { mkdir -p \"\$pkgdir/usr/share/$N\"; echo ok > \"\$pkgdir/usr/share/$N/ok\"; }" > APKBUILD
|
||||
abuild -d >&2 || true'
|
||||
cat /home/b/packages/*/x86_64/$N-$V-r0.apk`
|
||||
|
||||
func buildApk(t *testing.T, name, version string) []byte {
|
||||
t.Helper()
|
||||
cmd := exec.Command("docker", "run", "--rm", "-e", "N="+name, "-e", "V="+version, "alpine:3.20", "sh", "-c", abuildScript)
|
||||
var stderr strings.Builder
|
||||
cmd.Stderr = &stderr
|
||||
out, err := cmd.Output()
|
||||
if err != nil || len(out) == 0 {
|
||||
t.Fatalf("abuild %s-%s: %v\n%s", name, version, err, stderr.String())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestVirtualAlpineMerge merges two local alpine repos carrying the same
|
||||
// abuild-built package: the first member wins the duplicate and its download,
|
||||
// and a real apk installs packages from both members through the virtual.
|
||||
func TestVirtualAlpineMerge(t *testing.T) {
|
||||
network := os.Getenv("COMPOSE_NETWORK")
|
||||
internal := os.Getenv("ARTIFACTAPI_INTERNAL")
|
||||
if network == "" || internal == "" {
|
||||
t.Skip("COMPOSE_NETWORK/ARTIFACTAPI_INTERNAL not set; skipping real apk")
|
||||
}
|
||||
if _, err := exec.LookPath("docker"); err != nil {
|
||||
t.Skip("docker not available on the test host")
|
||||
}
|
||||
one, two := buildApk(t, "vapk-one", "1.0"), buildApk(t, "vapk-two", "2.0")
|
||||
|
||||
createRepo(t, `{"name":"vapk-a","package_type":"alpine","repo_type":"local"}`)
|
||||
createRepo(t, `{"name":"vapk-b","package_type":"alpine","repo_type":"local"}`)
|
||||
defer deleteRepo(t, "vapk-a")
|
||||
defer deleteRepo(t, "vapk-b")
|
||||
|
||||
const apkType = "application/vnd.android.package-archive"
|
||||
uploadFile(t, "vapk-a", "x86_64/vapk-one-1.0-r0.apk", one, apkType)
|
||||
uploadFile(t, "vapk-b", "x86_64/vapk-one-1.0-r0.apk", one, apkType)
|
||||
uploadFile(t, "vapk-b", "x86_64/vapk-two-2.0-r0.apk", two, apkType)
|
||||
|
||||
// Local indexes list uploads once their metadata is parsed asynchronously,
|
||||
// and the virtual caches its merge, so merge only once both members list all.
|
||||
for _, m := range []struct{ repo, pkg string }{{"vapk-a", "P:vapk-one\n"}, {"vapk-b", "P:vapk-two\n"}} {
|
||||
var idx string
|
||||
for deadline := time.Now().Add(15 * time.Second); !strings.Contains(idx, m.pkg) && time.Now().Before(deadline); time.Sleep(250 * time.Millisecond) {
|
||||
_, body := doRequest(t, http.MethodGet, api("/api/v1/local/"+m.repo+"/x86_64/APKINDEX.tar.gz"), nil, "")
|
||||
idx = apkIndexText(t, body)
|
||||
}
|
||||
}
|
||||
|
||||
createVirtual(t, `{"name":"vapk","package_type":"alpine","members":["vapk-a","vapk-b"]}`)
|
||||
defer deleteVirtual(t, "vapk")
|
||||
|
||||
resp, body := doRequest(t, http.MethodGet, api("/api/v1/virtual/vapk/x86_64/APKINDEX.tar.gz"), nil, "")
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("virtual APKINDEX: status %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
index := apkIndexText(t, body)
|
||||
if strings.Count(index, "P:vapk-one\n") != 1 || !strings.Contains(index, "P:vapk-two\n") {
|
||||
t.Fatalf("merged APKINDEX should hold vapk-one once and vapk-two:\n%s", index)
|
||||
}
|
||||
|
||||
noRedirect := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
||||
for pkg, member := range map[string]string{"vapk-one-1.0-r0.apk": "vapk-a", "vapk-two-2.0-r0.apk": "vapk-b"} {
|
||||
resp, err := noRedirect.Get(api("/api/v1/virtual/vapk/x86_64/" + pkg))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if loc := resp.Header.Get("Location"); resp.StatusCode != http.StatusFound || !strings.HasSuffix(loc, "/api/v1/local/"+member+"/x86_64/"+pkg) {
|
||||
t.Fatalf("%s: want 302 to %s, got %d %q", pkg, member, resp.StatusCode, loc)
|
||||
}
|
||||
}
|
||||
|
||||
script := "set -eu; " +
|
||||
"echo \"$URL\" > /etc/apk/repositories; " +
|
||||
"apk update --allow-untrusted; " +
|
||||
"apk add --allow-untrusted vapk-one vapk-two; " +
|
||||
"test -f /usr/share/vapk-one/ok && test -f /usr/share/vapk-two/ok"
|
||||
out, err := exec.Command("docker", "run", "--rm", "--network", network,
|
||||
"-e", "URL="+strings.TrimRight(internal, "/")+"/api/v1/virtual/vapk",
|
||||
"alpine:3.20", "sh", "-c", script).CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("real apk against alpine virtual failed: %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build dockere2e
|
||||
|
||||
package e2edocker
|
||||
|
||||
func init() {
|
||||
cachingCases = append(cachingCases, cachingCase{"vcs", "octo/repo/archive/refs/tags/v1.0.0.tar.gz", "octo/repo/archive/refs/tags/v1.0.0.tar.gz"})
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
//go:build dockere2e
|
||||
|
||||
package e2edocker
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/testsupport"
|
||||
)
|
||||
|
||||
// TestVirtualDebMerge merges two local deb repos carrying the same package: the
|
||||
// first member wins the duplicate, and a real apt sees both members' packages
|
||||
// and installs through the virtual.
|
||||
func TestVirtualDebMerge(t *testing.T) {
|
||||
createRepo(t, `{"name":"vdeb-a","package_type":"deb","repo_type":"local"}`)
|
||||
createRepo(t, `{"name":"vdeb-b","package_type":"deb","repo_type":"local"}`)
|
||||
defer deleteRepo(t, "vdeb-a")
|
||||
defer deleteRepo(t, "vdeb-b")
|
||||
|
||||
uploadFile(t, "vdeb-a", "vdeb-one_1.0.0_amd64.deb", testsupport.MinimalDeb("vdeb-one", "1.0.0", "amd64"), "application/vnd.debian.binary-package")
|
||||
uploadFile(t, "vdeb-b", "vdeb-one_1.0.0_amd64.deb", testsupport.MinimalDeb("vdeb-one", "1.0.0", "amd64"), "application/vnd.debian.binary-package")
|
||||
uploadFile(t, "vdeb-b", "vdeb-two_2.0.0_amd64.deb", testsupport.MinimalDeb("vdeb-two", "2.0.0", "amd64"), "application/vnd.debian.binary-package")
|
||||
|
||||
// Local indexes regenerate asynchronously; merge only once vdeb-b lists both.
|
||||
deadline := time.Now().Add(15 * time.Second)
|
||||
for {
|
||||
resp, body := doRequest(t, http.MethodGet, api("/api/v1/local/vdeb-b/Packages"), nil, "")
|
||||
if resp.StatusCode == http.StatusOK && strings.Contains(string(body), "vdeb-two") || time.Now().After(deadline) {
|
||||
break
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
|
||||
createVirtual(t, `{"name":"vdeb","package_type":"deb","members":["vdeb-a","vdeb-b"]}`)
|
||||
defer deleteVirtual(t, "vdeb")
|
||||
|
||||
_, body := doRequest(t, http.MethodGet, api("/api/v1/virtual/vdeb/Packages"), nil, "")
|
||||
pk := string(body)
|
||||
if strings.Count(pk, "Package: vdeb-one\n") != 1 || !strings.Contains(pk, "Filename: vdeb-a/pool/vdeb-one_1.0.0_amd64.deb") ||
|
||||
!strings.Contains(pk, "Filename: vdeb-b/pool/vdeb-two_2.0.0_amd64.deb") {
|
||||
t.Fatalf("merged Packages should hold vdeb-one from the first member and vdeb-two:\n%s", pk)
|
||||
}
|
||||
|
||||
network := os.Getenv("COMPOSE_NETWORK")
|
||||
internal := os.Getenv("ARTIFACTAPI_INTERNAL")
|
||||
if network == "" || internal == "" {
|
||||
t.Skip("COMPOSE_NETWORK/ARTIFACTAPI_INTERNAL not set; skipping real apt")
|
||||
}
|
||||
if _, err := exec.LookPath("docker"); err != nil {
|
||||
t.Skip("docker not available on the test host")
|
||||
}
|
||||
script := "set -euo pipefail; " +
|
||||
"rm -f /etc/apt/sources.list.d/*; " +
|
||||
"echo \"deb [trusted=yes] $URL ./\" > /etc/apt/sources.list.d/vdeb.list; " +
|
||||
"apt-get update; " +
|
||||
"apt-cache policy vdeb-one vdeb-two | tee /dev/stderr | grep -c virtual/vdeb | grep -qx 2; " +
|
||||
"apt-get install -y vdeb-one vdeb-two; " +
|
||||
"dpkg -s vdeb-one vdeb-two"
|
||||
out, err := exec.Command("docker", "run", "--rm", "--network", network,
|
||||
"-e", "URL="+strings.TrimRight(internal, "/")+"/api/v1/virtual/vdeb/",
|
||||
"debian:13", "bash", "-c", script).CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("real apt against deb virtual failed: %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVirtualDebSuite merges two proxied deb.debian.org remotes and drives a
|
||||
// real apt through the virtual's dists/<suite> layout (by-hash indexes).
|
||||
func TestVirtualDebSuite(t *testing.T) {
|
||||
network := os.Getenv("COMPOSE_NETWORK")
|
||||
internal := os.Getenv("ARTIFACTAPI_INTERNAL")
|
||||
if network == "" || internal == "" {
|
||||
t.Skip("COMPOSE_NETWORK/ARTIFACTAPI_INTERNAL not set; skipping real apt")
|
||||
}
|
||||
if _, err := exec.LookPath("docker"); err != nil {
|
||||
t.Skip("docker not available on the test host")
|
||||
}
|
||||
for _, n := range []string{"vdebs-a", "vdebs-b"} {
|
||||
createRepo(t, `{"name":"`+n+`","package_type":"deb","repo_type":"remote","base_url":"http://deb.debian.org/debian"}`)
|
||||
defer deleteRepo(t, n)
|
||||
}
|
||||
createVirtual(t, `{"name":"vdebs","package_type":"deb","members":["vdebs-a","vdebs-b"]}`)
|
||||
defer deleteVirtual(t, "vdebs")
|
||||
|
||||
resp, rel := doRequest(t, http.MethodGet, api("/api/v1/virtual/vdebs/dists/trixie-updates/Release"), nil, "")
|
||||
if resp.StatusCode != http.StatusOK || !strings.Contains(string(rel), "Acquire-By-Hash: yes\n") ||
|
||||
!strings.Contains(string(rel), " main/binary-amd64/Packages.gz\n") {
|
||||
t.Fatalf("merged suite Release: status %d:\n%s", resp.StatusCode, rel)
|
||||
}
|
||||
|
||||
script := "set -euo pipefail; " +
|
||||
"rm -f /etc/apt/sources.list.d/*; " +
|
||||
"echo \"deb [trusted=yes] $URL trixie-updates main\" > /etc/apt/sources.list.d/vdebs.list; " +
|
||||
"apt-get update; " +
|
||||
"idx=/tmp/avail; apt-cache dumpavail > $idx; " +
|
||||
"grep -q '^Filename: vdebs-a/pool/' $idx; if grep -q '^Filename: vdebs-b/' $idx; then echo 'vdebs-b not deduped'; exit 1; fi; " +
|
||||
"pkg=$(awk '/^Package:/{p=$2} /^Size:/{if(!m||$2<m){m=$2;b=p}} END{print b}' $idx); " +
|
||||
"cd /tmp && apt-get download $pkg && ls -l /tmp/*.deb"
|
||||
out, err := exec.Command("docker", "run", "--rm", "--network", network,
|
||||
"-e", "URL="+strings.TrimRight(internal, "/")+"/api/v1/virtual/vdebs/",
|
||||
"debian:13", "bash", "-c", script).CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("real apt against deb virtual suite failed: %v\n%s", err, out)
|
||||
}
|
||||
t.Logf("%s", out)
|
||||
resp, _ = doRequest(t, http.MethodGet, api("/api/v1/remote/vdebs-a/dists/trixie-updates/main/binary-amd64/Packages.xz"), nil, "")
|
||||
if src := resp.Header.Get("X-Artifact-Source"); resp.StatusCode != http.StatusOK || src != "cache" {
|
||||
t.Fatalf("member index the virtual merged should be cached: status %d source %q", resp.StatusCode, src)
|
||||
}
|
||||
}
|
||||
Binary file not shown.
+30
-24
@@ -150,31 +150,15 @@ func TestLocalAlpineIndex(t *testing.T) {
|
||||
apk := testsupport.MinimalApk("e2e-testpkg", "1.0-r0", "x86_64")
|
||||
uploadFile(t, "local-alpine", "x86_64/e2e-testpkg-1.0-r0.apk", apk, "application/vnd.android.package-archive")
|
||||
|
||||
// The index is generated asynchronously after upload; poll for it.
|
||||
resp, body := getEventually(t, api("/api/v1/local/local-alpine/x86_64/APKINDEX.tar.gz"), 15*time.Second)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("APKINDEX: status %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
zr, err := gzip.NewReader(bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("APKINDEX not gzip: %v", err)
|
||||
}
|
||||
tarBytes, _ := io.ReadAll(zr)
|
||||
tr := tar.NewReader(bytes.NewReader(tarBytes))
|
||||
// The index is served (empty) at once but lists the package only once the
|
||||
// upload's metadata is parsed asynchronously; poll until it does.
|
||||
var index string
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("APKINDEX not tar: %v", err)
|
||||
}
|
||||
if hdr.Name == "APKINDEX" {
|
||||
b, _ := io.ReadAll(tr)
|
||||
index = string(b)
|
||||
for deadline := time.Now().Add(15 * time.Second); !strings.Contains(index, "P:e2e-testpkg") && time.Now().Before(deadline); time.Sleep(250 * time.Millisecond) {
|
||||
resp, body := doRequest(t, http.MethodGet, api("/api/v1/local/local-alpine/x86_64/APKINDEX.tar.gz"), nil, "")
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("APKINDEX: status %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
index = apkIndexText(t, body)
|
||||
}
|
||||
for _, want := range []string{"P:e2e-testpkg", "V:1.0-r0", "A:x86_64", "C:Q1", "S:", "I:"} {
|
||||
if !strings.Contains(index, want) {
|
||||
@@ -183,7 +167,7 @@ func TestLocalAlpineIndex(t *testing.T) {
|
||||
}
|
||||
|
||||
// The .apk downloads back byte-identical from its arch path.
|
||||
resp, body = doRequest(t, http.MethodGet, api("/api/v1/local/local-alpine/x86_64/e2e-testpkg-1.0-r0.apk"), nil, "")
|
||||
resp, body := doRequest(t, http.MethodGet, api("/api/v1/local/local-alpine/x86_64/e2e-testpkg-1.0-r0.apk"), nil, "")
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("download apk: status %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
@@ -191,3 +175,25 @@ func TestLocalAlpineIndex(t *testing.T) {
|
||||
t.Fatalf("apk content mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func apkIndexText(t *testing.T, body []byte) string {
|
||||
t.Helper()
|
||||
zr, err := gzip.NewReader(bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("APKINDEX not gzip: %v", err)
|
||||
}
|
||||
tr := tar.NewReader(zr)
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
return ""
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("APKINDEX not tar: %v", err)
|
||||
}
|
||||
if hdr.Name == "APKINDEX" {
|
||||
b, _ := io.ReadAll(tr)
|
||||
return string(b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,13 +17,18 @@ import (
|
||||
const puppetHelloMetadata = `{"name":"unkin-hello","version":"0.1.0","author":"unkin","summary":"e2e","license":"Apache-2.0","source":"https://example.invalid","dependencies":[{"name":"puppetlabs/stdlib","version_requirement":">= 4.0.0 < 10.0.0"}]}`
|
||||
|
||||
func puppetModuleTarball(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
return puppetTarball(t, "unkin-hello-0.1.0", puppetHelloMetadata)
|
||||
}
|
||||
|
||||
func puppetTarball(t *testing.T, dir, metadata string) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
zw := gzip.NewWriter(&buf)
|
||||
tw := tar.NewWriter(zw)
|
||||
for name, body := range map[string]string{
|
||||
"unkin-hello-0.1.0/metadata.json": puppetHelloMetadata,
|
||||
"unkin-hello-0.1.0/manifests/init.pp": "class hello {}\n",
|
||||
dir + "/metadata.json": metadata,
|
||||
dir + "/manifests/init.pp": "class stub {}\n",
|
||||
} {
|
||||
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: int64(len(body)), ModTime: time.Now()}); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
//go:build dockere2e
|
||||
|
||||
package e2edocker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestVirtualPuppetInstall puts unkin-hello and its stdlib dependency on two
|
||||
// puppet locals and installs through a virtual over both with the real puppet
|
||||
// module tool (dependencies resolved) and r10k, downloads 302ing to each owner.
|
||||
func TestVirtualPuppetInstall(t *testing.T) {
|
||||
network := os.Getenv("COMPOSE_NETWORK")
|
||||
internal := strings.TrimRight(os.Getenv("ARTIFACTAPI_INTERNAL"), "/")
|
||||
if network == "" || internal == "" {
|
||||
t.Skip("COMPOSE_NETWORK/ARTIFACTAPI_INTERNAL not set; run via scripts/docker-e2e.sh")
|
||||
}
|
||||
if _, err := exec.LookPath("docker"); err != nil {
|
||||
t.Skip("docker not available on the test host")
|
||||
}
|
||||
|
||||
id := time.Now().UnixNano()
|
||||
a, b, virt := fmt.Sprintf("e2e-puppet-a-%d", id), fmt.Sprintf("e2e-puppet-b-%d", id), fmt.Sprintf("e2e-puppet-v-%d", id)
|
||||
modules := map[string]struct{ file, dir, meta string }{
|
||||
a: {"unkin-hello-0.1.0.tar.gz", "unkin-hello-0.1.0", puppetHelloMetadata},
|
||||
b: {"puppetlabs-stdlib-9.6.0.tar.gz", "puppetlabs-stdlib-9.6.0", `{"name":"puppetlabs-stdlib","version":"9.6.0","author":"puppetlabs","summary":"stub","license":"Apache-2.0","source":"https://example.invalid","dependencies":[]}`},
|
||||
}
|
||||
for repo, m := range modules {
|
||||
createRepo(t, fmt.Sprintf(`{"name":%q,"package_type":"puppet","repo_type":"local"}`, repo))
|
||||
defer deleteRepo(t, repo)
|
||||
if resp, body := doRequest(t, http.MethodPut, api("/api/v2/remotes/"+repo+"/files/"+m.file), puppetTarball(t, m.dir, m.meta), "application/gzip"); resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("upload %s: %d %s", m.file, resp.StatusCode, body)
|
||||
}
|
||||
defer doRequest(t, http.MethodDelete, api("/api/v2/remotes/"+repo+"/files/v3/files/"+m.file), nil, "")
|
||||
if resp, body := getEventually(t, api("/api/v1/local/"+repo+"/v3/releases/"+strings.TrimSuffix(m.file, ".tar.gz")), 15*time.Second); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("%s never indexed: %d %s", m.file, resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
createVirtual(t, fmt.Sprintf(`{"name":%q,"package_type":"puppet","members":[%q,%q]}`, virt, a, b))
|
||||
defer deleteVirtual(t, virt)
|
||||
|
||||
forge := internal + "/api/v1/virtual/" + virt
|
||||
run := func(image, script string) string {
|
||||
t.Helper()
|
||||
out, err := exec.Command("docker", "run", "--rm", "--network", network, "--entrypoint", "sh", image, "-c", "set -eu; "+script).CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("%s failed: %v\n%s", image, err, out)
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
out := run("ghcr.io/openvoxproject/openvoxagent:latest", "export PATH=/opt/puppetlabs/bin:$PATH; puppet module install unkin-hello --module_repository "+forge+" --target-dir /tmp/mods && puppet module list --color=false --modulepath /tmp/mods")
|
||||
for _, want := range []string{"unkin-hello (v0.1.0)", "puppetlabs-stdlib (v9.6.0)"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("puppet module install missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
|
||||
out = run("ghcr.io/voxpupuli/r10k:latest", "cd /tmp && printf 'forge:\\n baseurl: "+forge+"\\n' > r10k.yaml && "+
|
||||
"printf \"mod 'unkin-hello', '0.1.0'\\nmod 'puppetlabs-stdlib', '9.6.0'\\n\" > Puppetfile && "+
|
||||
"r10k puppetfile install -c r10k.yaml && cat modules/hello/metadata.json modules/stdlib/metadata.json")
|
||||
if !strings.Contains(out, `"unkin-hello"`) || !strings.Contains(out, `"puppetlabs-stdlib"`) {
|
||||
t.Fatalf("r10k did not install both modules:\n%s", out)
|
||||
}
|
||||
|
||||
noRedirect := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
||||
for repo, m := range modules {
|
||||
resp, err := noRedirect.Get(api("/api/v1/virtual/" + virt + "/v3/files/" + m.file))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
if loc := resp.Header.Get("Location"); resp.StatusCode != http.StatusFound || !strings.HasSuffix(loc, "/api/v1/local/"+repo+"/v3/files/"+m.file) {
|
||||
t.Errorf("%s = %d %q, want 302 to %s", m.file, resp.StatusCode, loc, repo)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -215,6 +215,10 @@ func (h *ProxyHandler) handleVirtual(w http.ResponseWriter, r *http.Request) {
|
||||
h.serveVirtualAnsible(w, r, *virt, path, proxyBaseURL)
|
||||
return
|
||||
}
|
||||
if virt.PackageType == models.PackagePuppet {
|
||||
h.serveVirtualPuppet(w, r, *virt, path, proxyBaseURL)
|
||||
return
|
||||
}
|
||||
if virt.PackageType == models.PackageGoProxy {
|
||||
h.serveVirtualGoProxy(w, r, *virt, path)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/provider/puppet"
|
||||
"git.unkin.net/unkin/artifactapi/internal/virtual"
|
||||
"git.unkin.net/unkin/artifactapi/pkg/models"
|
||||
)
|
||||
|
||||
// serveVirtualPuppet serves the merged Forge v3 module and release listings
|
||||
// and 302s release details and downloads to the member owning that release.
|
||||
func (h *ProxyHandler) serveVirtualPuppet(w http.ResponseWriter, r *http.Request, virt models.Virtual, path, proxyBaseURL string) {
|
||||
var doc any
|
||||
var err error
|
||||
switch {
|
||||
case path == "v3/releases":
|
||||
q := r.URL.Query()
|
||||
slug := puppet.NormalizeSlug(q.Get("module"))
|
||||
if !puppet.ValidSlug(slug) {
|
||||
forgeError(w, http.StatusBadRequest, "module is required")
|
||||
return
|
||||
}
|
||||
var d *virtual.PuppetDoc
|
||||
d, err = h.virtualEngine.PuppetReleases(r.Context(), virt, slug)
|
||||
releases := []any{}
|
||||
if errors.Is(err, virtual.ErrNotFound) {
|
||||
err = nil
|
||||
} else if err == nil {
|
||||
for _, rel := range d.Releases {
|
||||
releases = append(releases, rel)
|
||||
}
|
||||
}
|
||||
doc = puppet.ReleasesPage(slug, releases, q)
|
||||
case strings.HasPrefix(path, "v3/modules/"):
|
||||
slug := puppet.NormalizeSlug(strings.TrimPrefix(path, "v3/modules/"))
|
||||
if !puppet.ValidSlug(slug) {
|
||||
puppet.NotFound(w)
|
||||
return
|
||||
}
|
||||
var d *virtual.PuppetDoc
|
||||
if d, err = h.virtualEngine.PuppetReleases(r.Context(), virt, slug); err == nil {
|
||||
doc, err = virtual.PuppetModule(d)
|
||||
}
|
||||
default:
|
||||
var loc string
|
||||
loc, err = h.virtualEngine.PuppetRedirect(r.Context(), virt, path, proxyBaseURL)
|
||||
if err == nil {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Vary", "Host, X-Forwarded-Proto")
|
||||
http.Redirect(w, r, loc, http.StatusFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, virtual.ErrNotFound):
|
||||
puppet.NotFound(w)
|
||||
case errors.Is(err, virtual.ErrBadPath):
|
||||
forgeError(w, http.StatusBadRequest, err.Error())
|
||||
case err != nil:
|
||||
slog.Error("virtual puppet fetch failed", "virtual", virt.Name, "path", path, "error", err)
|
||||
forgeError(w, http.StatusBadGateway, "bad gateway")
|
||||
default:
|
||||
w.Header().Set("X-Artifact-Source", "virtual")
|
||||
puppet.WriteJSON(w, doc)
|
||||
}
|
||||
}
|
||||
|
||||
func forgeError(w http.ResponseWriter, code int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"message": http.StatusText(code), "errors": []string{msg}})
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/provider"
|
||||
"git.unkin.net/unkin/artifactapi/internal/virtual"
|
||||
"git.unkin.net/unkin/artifactapi/pkg/models"
|
||||
)
|
||||
|
||||
func TestPuppetVirtualOverLocals(t *testing.T) {
|
||||
h, srv := npmTestServer(t)
|
||||
h.virtualEngine = virtual.NewEngine(h.db, nil)
|
||||
a, b := npmRepo(t, h, models.PackagePuppet), npmRepo(t, h, models.PackagePuppet)
|
||||
for _, p := range []struct{ repo, version string }{{a, "0.1.0"}, {b, "0.1.0"}, {b, "0.2.0"}} {
|
||||
file := "v3/files/unkin-hello-" + p.version + ".tar.gz"
|
||||
meta := &provider.PuppetMetadata{RepoName: p.repo, FilePath: file, ContentHash: "sha256:00", Slug: "unkin-hello", Version: p.version, MetadataJSON: `{"name":"unkin-hello","version":"` + p.version + `"}`}
|
||||
if err := h.db.InsertPuppetMetadata(context.Background(), meta); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
virt := "v" + a
|
||||
if err := h.db.CreateVirtual(context.Background(), &models.Virtual{Name: virt, PackageType: models.PackagePuppet, Members: []string{a, b}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base := srv.URL + "/api/v1/virtual/" + virt
|
||||
|
||||
var page struct {
|
||||
Pagination struct {
|
||||
Total int
|
||||
Next *string
|
||||
}
|
||||
Results []struct {
|
||||
Version string
|
||||
FileURI string `json:"file_uri"`
|
||||
}
|
||||
}
|
||||
getJSON(t, base+"/v3/releases?module=unkin/hello&limit=1", &page)
|
||||
if page.Pagination.Total != 2 || len(page.Results) != 1 || page.Results[0].Version != "0.2.0" || page.Pagination.Next == nil {
|
||||
t.Fatalf("page 1 = %+v", page)
|
||||
}
|
||||
if *page.Pagination.Next != "/v3/releases?limit=1&module=unkin-hello&offset=1" || page.Results[0].FileURI != "/v3/files/unkin-hello-0.2.0.tar.gz" {
|
||||
t.Fatalf("links not relative to the forge root: next %q file_uri %q", *page.Pagination.Next, page.Results[0].FileURI)
|
||||
}
|
||||
getJSON(t, base+*page.Pagination.Next, &page)
|
||||
if len(page.Results) != 1 || page.Results[0].Version != "0.1.0" || page.Pagination.Next != nil {
|
||||
t.Fatalf("page 2 = %+v", page)
|
||||
}
|
||||
|
||||
var mod struct {
|
||||
CurrentRelease struct{ Version string } `json:"current_release"`
|
||||
Releases []struct{ Version string }
|
||||
}
|
||||
getJSON(t, base+"/v3/modules/unkin-hello", &mod)
|
||||
if mod.CurrentRelease.Version != "0.2.0" || len(mod.Releases) != 2 {
|
||||
t.Fatalf("module = %+v", mod)
|
||||
}
|
||||
|
||||
noRedirect := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
||||
for p, want := range map[string]string{
|
||||
"/v3/files/unkin-hello-0.1.0.tar.gz": "/api/v1/local/" + a + "/v3/files/unkin-hello-0.1.0.tar.gz",
|
||||
"/v3/releases/unkin-hello-0.2.0": "/api/v1/local/" + b + "/v3/releases/unkin-hello-0.2.0",
|
||||
} {
|
||||
resp, err := noRedirect.Get(base + p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusFound || resp.Header.Get("Location") != srv.URL+want || resp.Header.Get("Cache-Control") != "no-store" {
|
||||
t.Errorf("%s = %d %q %q, want 302 to %s", p, resp.StatusCode, resp.Header.Get("Location"), resp.Header.Get("Cache-Control"), want)
|
||||
}
|
||||
}
|
||||
|
||||
getJSON(t, base+"/v3/releases?module=unkin-absent", &page)
|
||||
if page.Pagination.Total != 0 || len(page.Results) != 0 {
|
||||
t.Fatalf("absent module = %+v", page)
|
||||
}
|
||||
for p, want := range map[string]int{
|
||||
"/v3/releases": http.StatusBadRequest,
|
||||
"/v3/modules/unkin-absent": http.StatusNotFound,
|
||||
"/v3/files/unkin-hello-0.9.0.tar.gz": http.StatusNotFound,
|
||||
} {
|
||||
if code, body := npmDo(t, http.MethodGet, base+p, ""); code != want || !strings.Contains(body, `"errors"`) {
|
||||
t.Errorf("%s = %d %s, want %d", p, code, body, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -307,6 +307,34 @@ func TestLocalFiles(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A repo recreated under a deleted repo's name must not inherit its files or
|
||||
// metadata, or re-uploading the same path 409s.
|
||||
func TestDeleteRemoteDropsLocalFiles(t *testing.T) {
|
||||
requireDB(t)
|
||||
seedRemote(t, "r-recreate")
|
||||
hash := seedBlob(t, "recreatehash")
|
||||
if err := testDB.CreateLocalFile(ctx(), "r-recreate", "x86_64/a-1.0-r0.apk", hash); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := testDB.InsertAlpineMetadata(ctx(), &provider.AlpineMetadata{RepoName: "r-recreate", FilePath: "x86_64/a-1.0-r0.apk", ContentHash: hash, Name: "a", Version: "1.0-r0", Arch: "x86_64"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := testDB.DeleteRemote(ctx(), "r-recreate"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seedRemote(t, "r-recreate")
|
||||
defer testDB.DeleteRemote(ctx(), "r-recreate")
|
||||
if f, err := testDB.GetLocalFile(ctx(), "r-recreate", "x86_64/a-1.0-r0.apk"); err != nil || f != nil {
|
||||
t.Fatalf("recreated repo inherited local file: %v %v", f, err)
|
||||
}
|
||||
if rows, err := testDB.ListAlpineMetadataEntries(ctx(), "r-recreate"); err != nil || len(rows) != 0 {
|
||||
t.Fatalf("recreated repo inherited alpine metadata: %v %v", rows, err)
|
||||
}
|
||||
if err := testDB.CreateLocalFile(ctx(), "r-recreate", "x86_64/a-1.0-r0.apk", hash); err != nil {
|
||||
t.Fatalf("re-upload after recreate: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVirtualsCRUD(t *testing.T) {
|
||||
requireDB(t)
|
||||
if err := testDB.CreateVirtual(ctx(), &models.Virtual{
|
||||
|
||||
@@ -3,6 +3,8 @@ package database
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/pkg/models"
|
||||
)
|
||||
|
||||
@@ -116,7 +118,16 @@ func (db *DB) UpdateRemote(ctx context.Context, r *models.Remote) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteRemote drops a remote with its local files and package metadata, so a
|
||||
// repo recreated under the same name starts empty.
|
||||
func (db *DB) DeleteRemote(ctx context.Context, name string) error {
|
||||
_, err := db.Pool.Exec(ctx, `DELETE FROM remotes WHERE name = $1`, name)
|
||||
return err
|
||||
return pgx.BeginFunc(ctx, db.Pool, func(tx pgx.Tx) error {
|
||||
for _, table := range []string{"local_files", "rpm_metadata", "deb_metadata", "alpine_metadata", "puppet_metadata", "helm_metadata"} {
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM `+table+` WHERE repo_name = $1`, name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err := tx.Exec(ctx, `DELETE FROM remotes WHERE name = $1`, name)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
package all
|
||||
|
||||
import _ "git.unkin.net/unkin/artifactapi/internal/provider/vcs"
|
||||
@@ -71,7 +71,7 @@ func (p *Provider) AuthHeaders(ctx context.Context, remote models.Remote) (http.
|
||||
h.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(remote.Username+":"+remote.Password)))
|
||||
return h, nil
|
||||
}
|
||||
if isGitHubHost(remote.BaseURL) {
|
||||
if IsGitHubHost(remote.BaseURL) {
|
||||
if c := githubauth.Server(); c != nil {
|
||||
tok, err := c.Token(ctx)
|
||||
if err != nil {
|
||||
@@ -85,11 +85,11 @@ func (p *Provider) AuthHeaders(ctx context.Context, remote models.Remote) (http.
|
||||
return h, nil
|
||||
}
|
||||
|
||||
// isGitHubHost reports whether rawURL targets a GitHub API/download host that
|
||||
// IsGitHubHost reports whether rawURL targets a GitHub API/download host that
|
||||
// accepts the server credential. objects.githubusercontent.com is deliberately
|
||||
// excluded: release-asset downloads 302-redirect there with a pre-signed URL
|
||||
// that must not carry an Authorization header.
|
||||
func isGitHubHost(rawURL string) bool {
|
||||
func IsGitHubHost(rawURL string) bool {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return false
|
||||
|
||||
@@ -29,7 +29,7 @@ const filesPrefix = "v3/files/"
|
||||
// tarball basename that `puppet module build` produces.
|
||||
var releaseRe = regexp.MustCompile(`^([a-z0-9]+)-([a-z][a-z0-9_]*)-(\d+\.\d+\.\d+(?:-[0-9a-z.-]+)?(?:\+[0-9a-z.-]+)?)$`)
|
||||
|
||||
func parseRelease(s string) (slug, version string, ok bool) {
|
||||
func ParseRelease(s string) (slug, version string, ok bool) {
|
||||
m := releaseRe.FindStringSubmatch(strings.ToLower(s))
|
||||
if m == nil {
|
||||
return "", "", false
|
||||
@@ -37,7 +37,12 @@ func parseRelease(s string) (slug, version string, ok bool) {
|
||||
return m[1] + "-" + m[2], m[3], true
|
||||
}
|
||||
|
||||
func normalizeSlug(s string) string {
|
||||
var slugRe = regexp.MustCompile(`^[a-z0-9]+-[a-z][a-z0-9_]*$`)
|
||||
|
||||
// ValidSlug reports whether s is a normalized <owner>-<name> module slug.
|
||||
func ValidSlug(s string) bool { return slugRe.MatchString(s) }
|
||||
|
||||
func NormalizeSlug(s string) string {
|
||||
return strings.ToLower(strings.ReplaceAll(s, "/", "-"))
|
||||
}
|
||||
|
||||
@@ -45,7 +50,7 @@ func normalizeSlug(s string) string {
|
||||
// resolve straight to the stored file.
|
||||
func (p *Provider) ValidateUpload(filePath string) (storagePath, contentType string, err error) {
|
||||
filename := strings.ToLower(path.Base(path.Clean("/" + filePath)))
|
||||
if _, _, ok := parseRelease(strings.TrimSuffix(filename, ".tar.gz")); !ok || !strings.HasSuffix(filename, ".tar.gz") {
|
||||
if _, _, ok := ParseRelease(strings.TrimSuffix(filename, ".tar.gz")); !ok || !strings.HasSuffix(filename, ".tar.gz") {
|
||||
return "", "", fmt.Errorf("file must be named <owner>-<module>-<version>.tar.gz")
|
||||
}
|
||||
return filesPrefix + filename, "application/gzip", nil
|
||||
@@ -82,7 +87,7 @@ func (p *Provider) AfterUpload(ctx context.Context, repoName, storagePath, conte
|
||||
slog.Error("puppet metadata: read failed", "repo", repoName, "path", storagePath, "error", err)
|
||||
return
|
||||
}
|
||||
slug, version, _ := parseRelease(strings.TrimSuffix(path.Base(storagePath), ".tar.gz"))
|
||||
slug, version, _ := ParseRelease(strings.TrimSuffix(path.Base(storagePath), ".tar.gz"))
|
||||
if meta.Slug != slug || meta.Version != version {
|
||||
slog.Error("puppet metadata: metadata.json does not match filename", "repo", repoName, "path", storagePath, "name", meta.Slug, "version", meta.Version)
|
||||
return
|
||||
@@ -158,7 +163,7 @@ func parseModule(r io.Reader) (*provider.PuppetMetadata, error) {
|
||||
if md.Name == "" || md.Version == "" {
|
||||
return nil, errors.New("metadata.json missing name or version")
|
||||
}
|
||||
return &provider.PuppetMetadata{Slug: normalizeSlug(md.Name), Version: md.Version, MetadataJSON: string(body)}, nil
|
||||
return &provider.PuppetMetadata{Slug: NormalizeSlug(md.Name), Version: md.Version, MetadataJSON: string(body)}, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,9 +186,9 @@ func (p *Provider) ServeLocalIndex(w http.ResponseWriter, r *http.Request, files
|
||||
case clean == "v3/releases":
|
||||
serveReleaseList(w, r, list)
|
||||
case strings.HasPrefix(clean, "v3/releases/"):
|
||||
slug, version, ok := parseRelease(strings.TrimPrefix(clean, "v3/releases/"))
|
||||
slug, version, ok := ParseRelease(strings.TrimPrefix(clean, "v3/releases/"))
|
||||
if !ok {
|
||||
notFound(w)
|
||||
NotFound(w)
|
||||
return true
|
||||
}
|
||||
metas, err := list(slug)
|
||||
@@ -193,23 +198,23 @@ func (p *Provider) ServeLocalIndex(w http.ResponseWriter, r *http.Request, files
|
||||
}
|
||||
for _, m := range metas {
|
||||
if m.Version == version {
|
||||
writeJSON(w, releaseObject(m))
|
||||
WriteJSON(w, releaseObject(m))
|
||||
return true
|
||||
}
|
||||
}
|
||||
notFound(w)
|
||||
NotFound(w)
|
||||
case strings.HasPrefix(clean, "v3/modules/"):
|
||||
slug := normalizeSlug(strings.TrimPrefix(clean, "v3/modules/"))
|
||||
slug := NormalizeSlug(strings.TrimPrefix(clean, "v3/modules/"))
|
||||
metas, err := list(slug)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return true
|
||||
}
|
||||
if len(metas) == 0 {
|
||||
notFound(w)
|
||||
NotFound(w)
|
||||
return true
|
||||
}
|
||||
writeJSON(w, moduleObject(metas))
|
||||
WriteJSON(w, moduleObject(metas))
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@@ -222,7 +227,22 @@ func (p *Provider) GenerateLocalIndex(ctx context.Context, files provider.FileSt
|
||||
|
||||
func serveReleaseList(w http.ResponseWriter, r *http.Request, list func(string) ([]provider.PuppetMetadata, error)) {
|
||||
q := r.URL.Query()
|
||||
slug := normalizeSlug(q.Get("module"))
|
||||
slug := NormalizeSlug(q.Get("module"))
|
||||
metas, err := list(slug)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
releases := make([]any, len(metas))
|
||||
for i, m := range metas {
|
||||
releases[i] = releaseObject(m)
|
||||
}
|
||||
WriteJSON(w, ReleasesPage(slug, releases, q))
|
||||
}
|
||||
|
||||
// ReleasesPage is one limit/offset page of a /v3/releases listing, with
|
||||
// pagination links relative to the Forge root.
|
||||
func ReleasesPage(slug string, releases []any, q url.Values) map[string]any {
|
||||
limit, err := strconv.Atoi(q.Get("limit"))
|
||||
if err != nil || limit <= 0 {
|
||||
limit = 20
|
||||
@@ -232,20 +252,10 @@ func serveReleaseList(w http.ResponseWriter, r *http.Request, list func(string)
|
||||
if err != nil || offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
metas, err := list(slug)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
offset = min(offset, len(metas))
|
||||
results := []map[string]any{}
|
||||
for _, m := range metas[offset : offset+min(limit, len(metas)-offset)] {
|
||||
results = append(results, releaseObject(m))
|
||||
}
|
||||
offset = min(offset, len(releases))
|
||||
|
||||
page := func(off int) any {
|
||||
if off < 0 || off >= len(metas) && off != 0 {
|
||||
if off < 0 || off >= len(releases) && off != 0 {
|
||||
return nil
|
||||
}
|
||||
v := url.Values{"limit": {strconv.Itoa(limit)}, "offset": {strconv.Itoa(off)}}
|
||||
@@ -258,7 +268,7 @@ func serveReleaseList(w http.ResponseWriter, r *http.Request, list func(string)
|
||||
if offset > 0 {
|
||||
previous = page(max(offset-limit, 0))
|
||||
}
|
||||
writeJSON(w, map[string]any{
|
||||
return map[string]any{
|
||||
"pagination": map[string]any{
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
@@ -266,10 +276,10 @@ func serveReleaseList(w http.ResponseWriter, r *http.Request, list func(string)
|
||||
"previous": previous,
|
||||
"current": page(offset),
|
||||
"next": page(offset + limit),
|
||||
"total": len(metas),
|
||||
"total": len(releases),
|
||||
},
|
||||
"results": results,
|
||||
})
|
||||
"results": releases[offset : offset+min(limit, len(releases)-offset)],
|
||||
}
|
||||
}
|
||||
|
||||
func owner(slug string) map[string]any {
|
||||
@@ -329,7 +339,7 @@ func moduleObject(metas []provider.PuppetMetadata) map[string]any {
|
||||
return obj
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v any) {
|
||||
func WriteJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
enc := json.NewEncoder(w)
|
||||
@@ -337,7 +347,7 @@ func writeJSON(w http.ResponseWriter, v any) {
|
||||
_ = enc.Encode(v)
|
||||
}
|
||||
|
||||
func notFound(w http.ResponseWriter) {
|
||||
func NotFound(w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = io.WriteString(w, `{"message":"404 Not Found","errors":["The requested resource could not be found"]}`)
|
||||
@@ -349,11 +359,11 @@ func sortReleases(metas []provider.PuppetMetadata) {
|
||||
if c := strings.Compare(a.Slug, b.Slug); c != 0 {
|
||||
return c
|
||||
}
|
||||
return compareSemver(b.Version, a.Version)
|
||||
return CompareSemver(b.Version, a.Version)
|
||||
})
|
||||
}
|
||||
|
||||
func compareSemver(a, b string) int {
|
||||
func CompareSemver(a, b string) int {
|
||||
a, _, _ = strings.Cut(a, "+")
|
||||
b, _, _ = strings.Cut(b, "+")
|
||||
aCore, aPre, aHasPre := strings.Cut(a, "-")
|
||||
|
||||
@@ -291,11 +291,11 @@ func TestFilesFallThrough(t *testing.T) {
|
||||
func TestCompareSemver(t *testing.T) {
|
||||
ordered := []string{"0.1.0", "0.2.0", "0.10.0", "1.0.0-alpha", "1.0.0-alpha.1", "1.0.0-beta", "1.0.0-beta.2", "1.0.0-beta.11", "1.0.0", "1.0.1+build"}
|
||||
for i := 1; i < len(ordered); i++ {
|
||||
if compareSemver(ordered[i-1], ordered[i]) >= 0 || compareSemver(ordered[i], ordered[i-1]) <= 0 {
|
||||
if CompareSemver(ordered[i-1], ordered[i]) >= 0 || CompareSemver(ordered[i], ordered[i-1]) <= 0 {
|
||||
t.Errorf("%s should sort before %s", ordered[i-1], ordered[i])
|
||||
}
|
||||
}
|
||||
if compareSemver("1.0.0+a", "1.0.0+b") != 0 {
|
||||
if CompareSemver("1.0.0+a", "1.0.0+b") != 0 {
|
||||
t.Error("build metadata must not affect precedence")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package puppet
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/auth"
|
||||
@@ -39,6 +40,32 @@ func (p *Provider) UpstreamURL(remote models.Remote, path string) string {
|
||||
return strings.TrimRight(remote.BaseURL, "/") + "/" + strings.TrimLeft(path, "/")
|
||||
}
|
||||
|
||||
// ForwardedQuery keeps the module filter and pagination on release listings.
|
||||
func (p *Provider) ForwardedQuery(path string) map[string]provider.QueryValidator {
|
||||
if path != "v3/releases" {
|
||||
return nil
|
||||
}
|
||||
return map[string]provider.QueryValidator{"module": moduleParam, "limit": pageLimit, "offset": pageOffset}
|
||||
}
|
||||
|
||||
func moduleParam(v string) (string, bool) {
|
||||
v = NormalizeSlug(v)
|
||||
return v, ValidSlug(v)
|
||||
}
|
||||
|
||||
func pageLimit(v string) (string, bool) {
|
||||
n, err := strconv.ParseUint(v, 10, 32)
|
||||
if err != nil || n == 0 {
|
||||
return "", false
|
||||
}
|
||||
return strconv.FormatUint(min(n, 100), 10), true
|
||||
}
|
||||
|
||||
func pageOffset(v string) (string, bool) {
|
||||
n, err := strconv.ParseUint(v, 10, 32)
|
||||
return strconv.FormatUint(n, 10), err == nil
|
||||
}
|
||||
|
||||
func (p *Provider) RewritesResponse() {}
|
||||
|
||||
func (p *Provider) RewriteResponse(body []byte, remote models.Remote, proxyBaseURL string) ([]byte, error) {
|
||||
|
||||
@@ -76,3 +76,28 @@ func TestAuthHeaders(t *testing.T) {
|
||||
t.Error("no credentials, no header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardedQuery(t *testing.T) {
|
||||
p := &Provider{}
|
||||
if p.ForwardedQuery("v3/files/a-b-1.0.0.tar.gz") != nil || p.ForwardedQuery("v3/modules/a-b") != nil {
|
||||
t.Fatal("only release listings forward a query")
|
||||
}
|
||||
f := p.ForwardedQuery("v3/releases")
|
||||
for name, c := range map[string]struct {
|
||||
in, want string
|
||||
ok bool
|
||||
}{
|
||||
"module": {"Puppetlabs/stdlib", "puppetlabs-stdlib", true},
|
||||
"limit": {"500", "100", true},
|
||||
"offset": {"40", "40", true},
|
||||
} {
|
||||
if got, ok := f[name](c.in); got != c.want || ok != c.ok {
|
||||
t.Errorf("%s(%q) = %q %v, want %q %v", name, c.in, got, ok, c.want, c.ok)
|
||||
}
|
||||
}
|
||||
for name, bad := range map[string]string{"module": "../x", "limit": "0", "offset": "-1"} {
|
||||
if _, ok := f[name](bad); ok {
|
||||
t.Errorf("%s(%q) accepted", name, bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
// Package vcs caches source archives and release assets from GitHub or Gitea,
|
||||
// keyed by ref: tags, commits and release assets are immutable, and branch
|
||||
// archives redirect to the archive of the commit the branch currently points at.
|
||||
package vcs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sync/singleflight"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/provider"
|
||||
"git.unkin.net/unkin/artifactapi/internal/provider/generic"
|
||||
"git.unkin.net/unkin/artifactapi/internal/proxy"
|
||||
"git.unkin.net/unkin/artifactapi/pkg/models"
|
||||
)
|
||||
|
||||
func init() {
|
||||
provider.Register(New())
|
||||
}
|
||||
|
||||
type kind int
|
||||
|
||||
const (
|
||||
tagArchive kind = iota + 1
|
||||
branchArchive
|
||||
commitArchive
|
||||
releaseAsset
|
||||
latestReleaseAsset
|
||||
)
|
||||
|
||||
// target is a parsed vcs request path.
|
||||
type target struct {
|
||||
Kind kind
|
||||
Owner, Repo string
|
||||
Name string // tag, branch, commit or asset
|
||||
Ext string
|
||||
}
|
||||
|
||||
var (
|
||||
segmentRe = regexp.MustCompile(`^[A-Za-z0-9._+-]+$`)
|
||||
commitRe = regexp.MustCompile(`^[0-9a-f]{40}$`)
|
||||
)
|
||||
|
||||
var errNotFound = errors.New("ref not found")
|
||||
|
||||
const (
|
||||
maxRefs = 4096
|
||||
negativeTTL = time.Minute
|
||||
)
|
||||
|
||||
// Provider embeds generic for upstream URLs, content types and GitHub/Basic auth.
|
||||
type Provider struct {
|
||||
generic.Provider
|
||||
|
||||
client *http.Client
|
||||
githubAPI string
|
||||
|
||||
group singleflight.Group
|
||||
mu sync.Mutex
|
||||
refs map[string]map[string]resolved
|
||||
}
|
||||
|
||||
// resolved is a branch lookup; an empty commit records a missing branch.
|
||||
type resolved struct {
|
||||
commit string
|
||||
at time.Time
|
||||
}
|
||||
|
||||
func New() *Provider {
|
||||
return &Provider{
|
||||
client: &http.Client{Timeout: 30 * time.Second},
|
||||
githubAPI: "https://api.github.com",
|
||||
refs: map[string]map[string]resolved{},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Provider) Type() models.PackageType { return models.PackageVCS }
|
||||
|
||||
func (p *Provider) Classify(path string) provider.Mutability {
|
||||
if ref, ok := parsePath(path); ok && (ref.Kind == branchArchive || ref.Kind == latestReleaseAsset) {
|
||||
return provider.Mutable
|
||||
}
|
||||
return provider.Immutable
|
||||
}
|
||||
|
||||
// parsePath accepts only the archive and release-download shapes, rejecting
|
||||
// any segment that could escape owner/repo or a ref.
|
||||
func parsePath(path string) (target, bool) {
|
||||
parts := strings.SplitN(strings.TrimPrefix(path, "/"), "/", 3)
|
||||
if len(parts) != 3 || !validSegment(parts[0]) || !validSegment(parts[1]) {
|
||||
return target{}, false
|
||||
}
|
||||
ref := target{Owner: parts[0], Repo: parts[1]}
|
||||
rest := parts[2]
|
||||
switch {
|
||||
case strings.HasPrefix(rest, "archive/"):
|
||||
name, ext, ok := archiveName(strings.TrimPrefix(rest, "archive/"))
|
||||
if !ok {
|
||||
return target{}, false
|
||||
}
|
||||
ref.Ext = ext
|
||||
switch {
|
||||
case strings.HasPrefix(name, "refs/tags/"):
|
||||
ref.Kind, ref.Name = tagArchive, strings.TrimPrefix(name, "refs/tags/")
|
||||
case strings.HasPrefix(name, "refs/heads/"):
|
||||
ref.Kind, ref.Name = branchArchive, strings.TrimPrefix(name, "refs/heads/")
|
||||
case commitRe.MatchString(name):
|
||||
return target{Kind: commitArchive, Owner: ref.Owner, Repo: ref.Repo, Name: name, Ext: ext}, true
|
||||
default:
|
||||
return target{}, false
|
||||
}
|
||||
return ref, validRefName(ref.Name)
|
||||
case strings.HasPrefix(rest, "releases/latest/download/"):
|
||||
ref.Kind, ref.Name = latestReleaseAsset, strings.TrimPrefix(rest, "releases/latest/download/")
|
||||
return ref, validSegment(ref.Name)
|
||||
case strings.HasPrefix(rest, "releases/download/"):
|
||||
tagAsset := strings.TrimPrefix(rest, "releases/download/")
|
||||
i := strings.LastIndex(tagAsset, "/")
|
||||
if i < 0 {
|
||||
return target{}, false
|
||||
}
|
||||
ref.Kind, ref.Name = releaseAsset, tagAsset[i+1:]
|
||||
return ref, validRefName(tagAsset[:i]) && validSegment(ref.Name)
|
||||
}
|
||||
return target{}, false
|
||||
}
|
||||
|
||||
func archiveName(s string) (string, string, bool) {
|
||||
for _, ext := range []string{".tar.gz", ".zip"} {
|
||||
if name, ok := strings.CutSuffix(s, ext); ok && name != "" {
|
||||
return name, ext, true
|
||||
}
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
func validSegment(s string) bool {
|
||||
return segmentRe.MatchString(s) && s != "." && s != ".." && !strings.Contains(s, "..")
|
||||
}
|
||||
|
||||
func validRefName(s string) bool {
|
||||
for _, seg := range strings.Split(s, "/") {
|
||||
if !validSegment(seg) || strings.HasPrefix(seg, ".") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ServeRemote rejects unsupported paths and redirects branch archives to the
|
||||
// immutable archive of the branch's current commit; everything else is proxied.
|
||||
func (p *Provider) ServeRemote(w http.ResponseWriter, r *http.Request, remote models.Remote, path, proxyBaseURL string, _ provider.RemoteMetadataStore) bool {
|
||||
ref, ok := parsePath(path)
|
||||
if !ok {
|
||||
http.Error(w, "unsupported vcs path", http.StatusNotFound)
|
||||
return true
|
||||
}
|
||||
if ref.Kind != branchArchive {
|
||||
return false
|
||||
}
|
||||
classifier := proxy.NewClassifier(p)
|
||||
if classifier.Classify(remote, path) == proxy.ClassDenied {
|
||||
http.Error(w, "access denied", http.StatusForbidden)
|
||||
return true
|
||||
}
|
||||
commit, err := p.resolve(r.Context(), remote, ref)
|
||||
var se *provider.UpstreamStatusError
|
||||
switch {
|
||||
case errors.Is(err, errNotFound):
|
||||
http.Error(w, "branch not found", http.StatusNotFound)
|
||||
return true
|
||||
case errors.As(err, &se) && (se.Status == http.StatusTooManyRequests || se.Status == http.StatusForbidden && !se.RetryAt.IsZero()):
|
||||
secs := 60
|
||||
if !se.RetryAt.IsZero() {
|
||||
secs = max(1, int(time.Until(se.RetryAt).Seconds()+1))
|
||||
}
|
||||
w.Header().Set("Retry-After", strconv.Itoa(secs))
|
||||
http.Error(w, "upstream rate limited", http.StatusServiceUnavailable)
|
||||
return true
|
||||
case err != nil:
|
||||
http.Error(w, "bad gateway", http.StatusBadGateway)
|
||||
return true
|
||||
}
|
||||
target := ref.Owner + "/" + ref.Repo + "/archive/" + commit + ref.Ext
|
||||
if classifier.Classify(remote, target) == proxy.ClassDenied {
|
||||
http.Error(w, "access denied", http.StatusForbidden)
|
||||
return true
|
||||
}
|
||||
http.Redirect(w, r, strings.TrimRight(proxyBaseURL, "/")+provider.RemoteMountPath+remote.Name+"/"+target, http.StatusFound)
|
||||
return true
|
||||
}
|
||||
|
||||
// resolve maps a branch to its commit, reusing the answer for mutable_ttl (a
|
||||
// missing branch for at least negativeTTL) and, with stale_on_error, past an
|
||||
// upstream failure. Concurrent lookups of one branch share a single API call.
|
||||
func (p *Provider) resolve(ctx context.Context, remote models.Remote, ref target) (string, error) {
|
||||
key := ref.Owner + "/" + ref.Repo + "\x00" + ref.Name
|
||||
ttl := time.Duration(remote.MutableTTL) * time.Second
|
||||
p.mu.Lock()
|
||||
prev, ok := p.refs[remote.Name][key]
|
||||
p.mu.Unlock()
|
||||
if ok && prev.commit != "" && time.Since(prev.at) < ttl {
|
||||
return prev.commit, nil
|
||||
}
|
||||
if ok && prev.commit == "" && time.Since(prev.at) < max(ttl, negativeTTL) {
|
||||
return "", errNotFound
|
||||
}
|
||||
v, err, _ := p.group.Do(remote.Name+"\x00"+key, func() (any, error) {
|
||||
commit, err := p.lookup(context.WithoutCancel(ctx), remote, ref)
|
||||
if err == nil || errors.Is(err, errNotFound) {
|
||||
p.store(remote.Name, key, commit)
|
||||
}
|
||||
return commit, err
|
||||
})
|
||||
if err != nil {
|
||||
if ok && prev.commit != "" && remote.StaleOnError && !errors.Is(err, errNotFound) {
|
||||
return prev.commit, nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return v.(string), nil
|
||||
}
|
||||
|
||||
// store records a lookup, evicting the remote's oldest entry once it holds maxRefs.
|
||||
func (p *Provider) store(remote, key, commit string) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
m := p.refs[remote]
|
||||
if m == nil {
|
||||
m = map[string]resolved{}
|
||||
p.refs[remote] = m
|
||||
}
|
||||
if _, ok := m[key]; !ok && len(m) >= maxRefs {
|
||||
var oldest string
|
||||
for k, e := range m {
|
||||
if oldest == "" || e.at.Before(m[oldest].at) {
|
||||
oldest = k
|
||||
}
|
||||
}
|
||||
delete(m, oldest)
|
||||
}
|
||||
m[key] = resolved{commit: commit, at: time.Now()}
|
||||
}
|
||||
|
||||
func (p *Provider) lookup(ctx context.Context, remote models.Remote, ref target) (string, error) {
|
||||
segs := strings.Split(ref.Name, "/")
|
||||
for i, s := range segs {
|
||||
segs[i] = url.PathEscape(s)
|
||||
}
|
||||
branch := strings.Join(segs, "/")
|
||||
github := generic.IsGitHubHost(remote.BaseURL)
|
||||
api := strings.TrimRight(remote.BaseURL, "/") + "/api/v1/repos/" + ref.Owner + "/" + ref.Repo + "/branches/" + branch
|
||||
if github {
|
||||
api = p.githubAPI + "/repos/" + ref.Owner + "/" + ref.Repo + "/git/ref/heads/" + branch
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, api, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
h, err := p.AuthHeaders(ctx, remote)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header = h
|
||||
if github {
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
}
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusUnprocessableEntity {
|
||||
return "", errNotFound
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", provider.NewUpstreamStatusError(api, resp)
|
||||
}
|
||||
var b struct {
|
||||
Commit struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"commit"`
|
||||
Object struct {
|
||||
SHA string `json:"sha"`
|
||||
} `json:"object"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
commit := b.Commit.ID
|
||||
if github {
|
||||
commit = b.Object.SHA
|
||||
}
|
||||
if !commitRe.MatchString(commit) {
|
||||
return "", fmt.Errorf("branch lookup %s: unexpected commit %q", api, commit)
|
||||
}
|
||||
return commit, nil
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
package vcs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/githubauth"
|
||||
"git.unkin.net/unkin/artifactapi/internal/provider"
|
||||
"git.unkin.net/unkin/artifactapi/pkg/models"
|
||||
)
|
||||
|
||||
const (
|
||||
shaA = "4f2cfe92e3499f8581bf39db77e0a1431fa7b55c"
|
||||
shaB = "7fa5434dbc083c853eef435d1915020345219d2f"
|
||||
)
|
||||
|
||||
func TestParsePath(t *testing.T) {
|
||||
tests := []struct {
|
||||
path string
|
||||
ok bool
|
||||
kind kind
|
||||
name string
|
||||
}{
|
||||
{"cli/cli/archive/refs/tags/v2.60.0.tar.gz", true, tagArchive, "v2.60.0"},
|
||||
{"/cli/cli/archive/refs/tags/v2.60.0.zip", true, tagArchive, "v2.60.0"},
|
||||
{"o/r/archive/refs/tags/release/1.0.tar.gz", true, tagArchive, "release/1.0"},
|
||||
{"o/r/archive/refs/heads/feature/x.tar.gz", true, branchArchive, "feature/x"},
|
||||
{"o/r/archive/" + shaA + ".tar.gz", true, commitArchive, shaA},
|
||||
{"o/r/releases/download/v1.0/tool_linux_amd64.tar.gz", true, releaseAsset, "tool_linux_amd64.tar.gz"},
|
||||
{"o/r/releases/latest/download/tool.zip", true, latestReleaseAsset, "tool.zip"},
|
||||
{"o/r/archive/main.tar.gz", false, 0, ""},
|
||||
{"o/r/archive/" + shaA[:7] + ".tar.gz", false, 0, ""},
|
||||
{"o/r/archive/refs/tags/v1.tar.bz2", false, 0, ""},
|
||||
{"o/r/archive/refs/tags/.tar.gz", false, 0, ""},
|
||||
{"../r/archive/refs/tags/v1.tar.gz", false, 0, ""},
|
||||
{"o/../archive/refs/tags/v1.tar.gz", false, 0, ""},
|
||||
{"o/r/archive/refs/tags/../../../etc/passwd.tar.gz", false, 0, ""},
|
||||
{"o/r/archive/refs/heads/a//b.tar.gz", false, 0, ""},
|
||||
{"o/r/archive/refs/heads/.hidden.tar.gz", false, 0, ""},
|
||||
{"o/r/archive/refs/heads/a..b.tar.gz", false, 0, ""},
|
||||
{"o/r/archive/refs/tags/v1%2f.tar.gz", false, 0, ""},
|
||||
{"o/r/releases/download/v1/../x", false, 0, ""},
|
||||
{"o/r/releases/download/asset", false, 0, ""},
|
||||
{"o/r/raw/main/README.md", false, 0, ""},
|
||||
{"o/r", false, 0, ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
ref, ok := parsePath(tt.path)
|
||||
if ok != tt.ok || (ok && (ref.Kind != tt.kind || ref.Name != tt.name)) {
|
||||
t.Errorf("parsePath(%q) = %+v, %v; want kind %v name %q ok %v", tt.path, ref, ok, tt.kind, tt.name, tt.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassify(t *testing.T) {
|
||||
p := New()
|
||||
for path, want := range map[string]provider.Mutability{
|
||||
"o/r/archive/refs/tags/v1.tar.gz": provider.Immutable,
|
||||
"o/r/archive/" + shaA + ".zip": provider.Immutable,
|
||||
"o/r/releases/download/v1/a.bin": provider.Immutable,
|
||||
"o/r/archive/refs/heads/main.tar.gz": provider.Mutable,
|
||||
"o/r/releases/latest/download/webui.zip": provider.Mutable,
|
||||
} {
|
||||
if got := p.Classify(path); got != want {
|
||||
t.Errorf("Classify(%q) = %v, want %v", path, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type upstream struct {
|
||||
srv *httptest.Server
|
||||
hits atomic.Int32
|
||||
commit atomic.Value
|
||||
status atomic.Int32
|
||||
header atomic.Value
|
||||
accept atomic.Value
|
||||
auth atomic.Value
|
||||
path atomic.Value
|
||||
gate chan struct{}
|
||||
}
|
||||
|
||||
func newUpstream(t *testing.T, gitea bool) *upstream {
|
||||
u := &upstream{}
|
||||
u.commit.Store(shaA)
|
||||
u.status.Store(http.StatusOK)
|
||||
u.header.Store(http.Header{})
|
||||
u.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
u.hits.Add(1)
|
||||
if u.gate != nil {
|
||||
<-u.gate
|
||||
}
|
||||
u.accept.Store(r.Header.Get("Accept"))
|
||||
u.auth.Store(r.Header.Get("Authorization"))
|
||||
u.path.Store(r.URL.EscapedPath())
|
||||
for k, v := range u.header.Load().(http.Header) {
|
||||
w.Header()[k] = v
|
||||
}
|
||||
if s := int(u.status.Load()); s != http.StatusOK {
|
||||
w.WriteHeader(s)
|
||||
return
|
||||
}
|
||||
if gitea {
|
||||
_, _ = w.Write([]byte(`{"name":"main","commit":{"id":"` + u.commit.Load().(string) + `"}}`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"ref":"refs/heads/main","object":{"sha":"` + u.commit.Load().(string) + `","type":"commit"}}`))
|
||||
}))
|
||||
t.Cleanup(u.srv.Close)
|
||||
return u
|
||||
}
|
||||
|
||||
func serve(p *Provider, remote models.Remote, path string) (*httptest.ResponseRecorder, bool) {
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/api/v1/remote/"+remote.Name+"/"+path, nil)
|
||||
return w, p.ServeRemote(w, r, remote, path, "http://proxy", nil)
|
||||
}
|
||||
|
||||
// age backdates every cached lookup of a remote by d.
|
||||
func age(p *Provider, remote string, d time.Duration) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
for k, e := range p.refs[remote] {
|
||||
e.at = e.at.Add(-d)
|
||||
p.refs[remote][k] = e
|
||||
}
|
||||
}
|
||||
|
||||
type staticCred string
|
||||
|
||||
func (s staticCred) Token(context.Context) (string, error) { return string(s), nil }
|
||||
|
||||
func TestServeRemoteGiteaBranchRedirectsToCommit(t *testing.T) {
|
||||
u := newUpstream(t, true)
|
||||
p := New()
|
||||
remote := models.Remote{Name: "gitea", BaseURL: u.srv.URL, MutableTTL: 3600, Username: "bot", Password: "pw"}
|
||||
|
||||
w, handled := serve(p, remote, "unkin/app/archive/refs/heads/feature/x.zip")
|
||||
if !handled || w.Code != http.StatusFound {
|
||||
t.Fatalf("handled=%v code=%d", handled, w.Code)
|
||||
}
|
||||
if got, want := w.Header().Get("Location"), "http://proxy/api/v1/remote/gitea/unkin/app/archive/"+shaA+".zip"; got != want {
|
||||
t.Errorf("Location = %q, want %q", got, want)
|
||||
}
|
||||
if got := u.path.Load(); got != "/api/v1/repos/unkin/app/branches/feature/x" {
|
||||
t.Errorf("api path = %q", got)
|
||||
}
|
||||
if got := u.auth.Load(); got != "Basic Ym90OnB3" {
|
||||
t.Errorf("Authorization = %q, want remote basic auth", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeRemoteGitHubBranchUsesAPI(t *testing.T) {
|
||||
githubauth.SetServer(staticCred("ghs_server"))
|
||||
t.Cleanup(func() { githubauth.SetServer(nil) })
|
||||
u := newUpstream(t, false)
|
||||
p := New()
|
||||
p.githubAPI = u.srv.URL
|
||||
|
||||
for _, base := range []string{"https://github.com", "https://www.github.com/", "https://api.github.com"} {
|
||||
remote := models.Remote{Name: base, BaseURL: base, MutableTTL: 3600}
|
||||
w, _ := serve(p, remote, "cli/cli/archive/refs/heads/feature/x.tar.gz")
|
||||
if !strings.HasSuffix(w.Header().Get("Location"), "/cli/cli/archive/"+shaA+".tar.gz") {
|
||||
t.Fatalf("%s: Location = %q", base, w.Header().Get("Location"))
|
||||
}
|
||||
if u.path.Load() != "/repos/cli/cli/git/ref/heads/feature/x" || u.accept.Load() != "application/vnd.github+json" {
|
||||
t.Errorf("%s: api path %q accept %q", base, u.path.Load(), u.accept.Load())
|
||||
}
|
||||
if got := u.auth.Load(); got != "Bearer ghs_server" {
|
||||
t.Errorf("%s: Authorization = %q, want server credential", base, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeRemoteBranchResolutionHonoursMutableTTL(t *testing.T) {
|
||||
u := newUpstream(t, true)
|
||||
p := New()
|
||||
remote := models.Remote{Name: "g", BaseURL: u.srv.URL, MutableTTL: 600}
|
||||
path := "o/r/archive/refs/heads/main.tar.gz"
|
||||
|
||||
serve(p, remote, path)
|
||||
u.commit.Store(shaB)
|
||||
age(p, remote.Name, 599*time.Second)
|
||||
w, _ := serve(p, remote, path)
|
||||
if u.hits.Load() != 1 || !strings.Contains(w.Header().Get("Location"), shaA) {
|
||||
t.Fatalf("within ttl: hits=%d location=%q", u.hits.Load(), w.Header().Get("Location"))
|
||||
}
|
||||
|
||||
age(p, remote.Name, 2*time.Second)
|
||||
w, _ = serve(p, remote, path)
|
||||
if u.hits.Load() != 2 || !strings.Contains(w.Header().Get("Location"), shaB) {
|
||||
t.Fatalf("past ttl: hits=%d location=%q", u.hits.Load(), w.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeRemoteBranchUpstreamFailure(t *testing.T) {
|
||||
path := "o/r/archive/refs/heads/main.tar.gz"
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
stale bool
|
||||
want int
|
||||
}{
|
||||
{"stale", true, http.StatusFound},
|
||||
{"strict", false, http.StatusBadGateway},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
u := newUpstream(t, true)
|
||||
p := New()
|
||||
remote := models.Remote{Name: tc.name, BaseURL: u.srv.URL, StaleOnError: tc.stale}
|
||||
serve(p, remote, path)
|
||||
u.status.Store(http.StatusInternalServerError)
|
||||
if w, _ := serve(p, remote, path); w.Code != tc.want {
|
||||
t.Fatalf("code = %d, want %d", w.Code, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeRemoteBranchRateLimited(t *testing.T) {
|
||||
reset := strconv.FormatInt(time.Now().Add(120*time.Second).Unix(), 10)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
status int
|
||||
header http.Header
|
||||
want int
|
||||
retry [2]int
|
||||
}{
|
||||
{"primary 403", http.StatusForbidden, http.Header{"X-Ratelimit-Remaining": {"0"}, "X-Ratelimit-Reset": {reset}}, http.StatusServiceUnavailable, [2]int{100, 122}},
|
||||
{"secondary 403", http.StatusForbidden, http.Header{"Retry-After": {"30"}}, http.StatusServiceUnavailable, [2]int{30, 31}},
|
||||
{"429", http.StatusTooManyRequests, http.Header{"Retry-After": {"5"}}, http.StatusServiceUnavailable, [2]int{5, 6}},
|
||||
{"429 no hint", http.StatusTooManyRequests, http.Header{}, http.StatusServiceUnavailable, [2]int{60, 60}},
|
||||
{"plain 403", http.StatusForbidden, http.Header{}, http.StatusBadGateway, [2]int{}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
u := newUpstream(t, false)
|
||||
u.status.Store(int32(tc.status))
|
||||
u.header.Store(tc.header)
|
||||
p := New()
|
||||
p.githubAPI = u.srv.URL
|
||||
w, _ := serve(p, models.Remote{Name: "gh", BaseURL: "https://github.com"}, "o/r/archive/refs/heads/main.tar.gz")
|
||||
if w.Code != tc.want {
|
||||
t.Fatalf("code = %d, want %d", w.Code, tc.want)
|
||||
}
|
||||
ra := w.Header().Get("Retry-After")
|
||||
if tc.want != http.StatusServiceUnavailable {
|
||||
if ra != "" {
|
||||
t.Fatalf("Retry-After = %q on %d", ra, w.Code)
|
||||
}
|
||||
return
|
||||
}
|
||||
if n, err := strconv.Atoi(ra); err != nil || n < tc.retry[0] || n > tc.retry[1] {
|
||||
t.Fatalf("Retry-After = %q, want %d..%d", ra, tc.retry[0], tc.retry[1])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeRemoteBranchNotFoundIsCached(t *testing.T) {
|
||||
u := newUpstream(t, false)
|
||||
u.status.Store(http.StatusNotFound)
|
||||
p := New()
|
||||
p.githubAPI = u.srv.URL
|
||||
remote := models.Remote{Name: "gh", BaseURL: "https://github.com"}
|
||||
path := "o/r/archive/refs/heads/nope.tar.gz"
|
||||
|
||||
for range 3 {
|
||||
if w, _ := serve(p, remote, path); w.Code != http.StatusNotFound {
|
||||
t.Fatalf("code = %d, want 404", w.Code)
|
||||
}
|
||||
}
|
||||
if u.hits.Load() != 1 {
|
||||
t.Fatalf("hits = %d, want 1 within negative ttl", u.hits.Load())
|
||||
}
|
||||
u.status.Store(http.StatusOK)
|
||||
age(p, remote.Name, negativeTTL)
|
||||
if w, _ := serve(p, remote, path); w.Code != http.StatusFound || u.hits.Load() != 2 {
|
||||
t.Fatalf("after negative ttl: code=%d hits=%d", w.Code, u.hits.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeRemoteConcurrentLookupsShareOneCall(t *testing.T) {
|
||||
u := newUpstream(t, true)
|
||||
u.gate = make(chan struct{})
|
||||
p := New()
|
||||
remote := models.Remote{Name: "g", BaseURL: u.srv.URL, MutableTTL: 600}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
codes := make([]int, 20)
|
||||
for i := range codes {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
w, _ := serve(p, remote, "o/r/archive/refs/heads/main.tar.gz")
|
||||
codes[i] = w.Code
|
||||
}()
|
||||
}
|
||||
for u.hits.Load() == 0 {
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
close(u.gate)
|
||||
wg.Wait()
|
||||
if u.hits.Load() != 1 {
|
||||
t.Fatalf("hits = %d, want 1", u.hits.Load())
|
||||
}
|
||||
for i, c := range codes {
|
||||
if c != http.StatusFound {
|
||||
t.Fatalf("request %d: code %d", i, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreBoundsEntriesPerRemote(t *testing.T) {
|
||||
p := New()
|
||||
for i := range maxRefs + 10 {
|
||||
p.store("a", fmt.Sprint(i), "")
|
||||
}
|
||||
p.store("b", "x", shaA)
|
||||
if n := len(p.refs["a"]); n != maxRefs {
|
||||
t.Fatalf("remote a holds %d entries, want %d", n, maxRefs)
|
||||
}
|
||||
if len(p.refs["b"]) != 1 {
|
||||
t.Fatalf("remote b must keep its own budget")
|
||||
}
|
||||
if _, ok := p.refs["a"][fmt.Sprint(maxRefs+9)]; !ok {
|
||||
t.Fatalf("newest entry evicted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeRemoteRejectsBadCommit(t *testing.T) {
|
||||
u := newUpstream(t, true)
|
||||
u.commit.Store("../../etc")
|
||||
w, _ := serve(New(), models.Remote{Name: "g", BaseURL: u.srv.URL}, "o/r/archive/refs/heads/main.tar.gz")
|
||||
if w.Code != http.StatusBadGateway {
|
||||
t.Fatalf("code = %d, want 502", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeRemoteAllowlist(t *testing.T) {
|
||||
u := newUpstream(t, true)
|
||||
remote := models.Remote{Name: "g", BaseURL: u.srv.URL, Patterns: []string{"^allowed/repo/"}}
|
||||
if w, _ := serve(New(), remote, "other/repo/archive/refs/heads/main.tar.gz"); w.Code != http.StatusForbidden {
|
||||
t.Fatalf("code = %d, want 403", w.Code)
|
||||
}
|
||||
if u.hits.Load() != 0 {
|
||||
t.Fatalf("denied branch must not hit the API")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeRemoteBlockedRedirectTarget(t *testing.T) {
|
||||
u := newUpstream(t, true)
|
||||
remote := models.Remote{Name: "g", BaseURL: u.srv.URL, Blocklist: []string{"/archive/" + shaA}}
|
||||
w, _ := serve(New(), remote, "o/r/archive/refs/heads/main.tar.gz")
|
||||
if w.Code != http.StatusForbidden || w.Header().Get("Location") != "" {
|
||||
t.Fatalf("code = %d location %q, want 403 without redirect", w.Code, w.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeRemotePassesThroughAndRejects(t *testing.T) {
|
||||
p := New()
|
||||
remote := models.Remote{Name: "g", BaseURL: "https://github.com"}
|
||||
if _, handled := serve(p, remote, "o/r/archive/refs/tags/v1.tar.gz"); handled {
|
||||
t.Error("tag archives must go through the proxy engine")
|
||||
}
|
||||
if _, handled := serve(p, remote, "o/r/releases/download/v1/a.bin"); handled {
|
||||
t.Error("release assets must go through the proxy engine")
|
||||
}
|
||||
if w, handled := serve(p, remote, "o/r/raw/main/x"); !handled || w.Code != http.StatusNotFound {
|
||||
t.Errorf("unsupported path: handled=%v code=%d", handled, w.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package virtual
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AlpineMember is one member's decompressed APKINDEX for a directory; Index is
|
||||
// nil when the member does not serve that directory.
|
||||
type AlpineMember struct {
|
||||
RemoteName string
|
||||
Index []byte
|
||||
}
|
||||
|
||||
// AlpineRepo is a merged directory: its APKINDEX.tar.gz plus, per
|
||||
// "<name>-<version>.apk" apk will request, the member that owns it.
|
||||
type AlpineRepo struct {
|
||||
Index []byte
|
||||
Owners map[string][]byte
|
||||
}
|
||||
|
||||
func (r *AlpineRepo) index() []byte { return r.Index }
|
||||
func (r *AlpineRepo) files() map[string][]byte { return r.Owners }
|
||||
|
||||
// MergeAlpine merges members' APKINDEX records. Members are in priority order:
|
||||
// the first to carry a (P, V, A) wins. The index is unsigned, like the local
|
||||
// and github_alpine indexes it merges.
|
||||
func MergeAlpine(members []AlpineMember) *AlpineRepo {
|
||||
repo := &AlpineRepo{Owners: map[string][]byte{}}
|
||||
seen := map[string]bool{}
|
||||
var b bytes.Buffer
|
||||
for _, m := range members {
|
||||
for _, rec := range splitStanzas(m.Index) {
|
||||
p, v, a := stanzaField(rec, "P"), stanzaField(rec, "V"), stanzaField(rec, "A")
|
||||
key := p + "\x00" + v + "\x00" + a
|
||||
if p == "" || v == "" || seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
if _, ok := repo.Owners[p+"-"+v+".apk"]; !ok {
|
||||
repo.Owners[p+"-"+v+".apk"] = []byte(m.RemoteName)
|
||||
}
|
||||
b.WriteString(rec)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
}
|
||||
var t bytes.Buffer
|
||||
tw := tar.NewWriter(&t)
|
||||
_ = tw.WriteHeader(&tar.Header{Name: "APKINDEX", Mode: 0o644, Size: int64(b.Len()), Typeflag: tar.TypeReg, ModTime: time.Unix(0, 0)})
|
||||
_, _ = tw.Write(b.Bytes())
|
||||
_ = tw.Close()
|
||||
repo.Index = gzipDeterministic(t.Bytes())
|
||||
return repo
|
||||
}
|
||||
|
||||
// apkIndexText extracts the APKINDEX entry from an APKINDEX.tar.gz, skipping a
|
||||
// leading signature stream if present.
|
||||
func apkIndexText(body []byte) ([]byte, error) {
|
||||
zr, err := gzip.NewReader(bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tr := tar.NewReader(zr)
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil, fmt.Errorf("no APKINDEX entry")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if hdr.Name == "APKINDEX" {
|
||||
return io.ReadAll(tr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// alpinePath splits a request into its directory and file, or ok is false for
|
||||
// anything but an APKINDEX.tar.gz or .apk.
|
||||
func alpinePath(p string) (dir, file string, ok bool) {
|
||||
i := strings.LastIndex(p, "/")
|
||||
dir, file = p[:i+1], p[i+1:]
|
||||
return dir, file, dir != "" && (file == "APKINDEX.tar.gz" || strings.HasSuffix(file, ".apk"))
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package virtual
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/pkg/models"
|
||||
)
|
||||
|
||||
func apkRec(p, v, a string) string {
|
||||
return "C:Q1" + p + v + "\nP:" + p + "\nV:" + v + "\nA:" + a + "\nS:10\n"
|
||||
}
|
||||
|
||||
func apkTarGz(t *testing.T, name string, body []byte, closeTar bool) []byte {
|
||||
t.Helper()
|
||||
var tb bytes.Buffer
|
||||
tw := tar.NewWriter(&tb)
|
||||
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: int64(len(body)), Typeflag: tar.TypeReg}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tw.Write(body)
|
||||
if closeTar {
|
||||
tw.Close()
|
||||
} else {
|
||||
tw.Flush()
|
||||
}
|
||||
var gb bytes.Buffer
|
||||
gz := gzip.NewWriter(&gb)
|
||||
gz.Write(tb.Bytes())
|
||||
gz.Close()
|
||||
return gb.Bytes()
|
||||
}
|
||||
|
||||
func TestMergeAlpine(t *testing.T) {
|
||||
repo := MergeAlpine([]AlpineMember{
|
||||
{RemoteName: "a", Index: []byte(apkRec("foo", "1.0-r0", "x86_64") + "\n" + apkRec("bar", "1-r0", "noarch") + "\nC:Q1x\nV:9\n")},
|
||||
{RemoteName: "b"},
|
||||
{RemoteName: "c", Index: []byte(apkRec("foo", "1.0-r0", "x86_64") + "\n" + apkRec("foo", "2.0-r0", "x86_64") + "\n" + apkRec("bar", "1-r0", "x86_64"))},
|
||||
})
|
||||
idx, err := apkIndexText(repo.Index)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recs := splitStanzas(idx)
|
||||
if len(recs) != 4 || strings.Count(string(idx), "P:foo\nV:1.0-r0\n") != 1 {
|
||||
t.Fatalf("want foo 1.0 once, foo 2.0 and bar for both arches, record without P dropped:\n%s", idx)
|
||||
}
|
||||
for file, want := range map[string]string{"foo-1.0-r0.apk": "a", "foo-2.0-r0.apk": "c", "bar-1-r0.apk": "a"} {
|
||||
if got := string(repo.Owners[file]); got != want {
|
||||
t.Errorf("%s owned by %q, want %q", file, got, want)
|
||||
}
|
||||
}
|
||||
if again := MergeAlpine([]AlpineMember{{RemoteName: "a", Index: idx}}); !bytes.Equal(again.Index, MergeAlpine([]AlpineMember{{RemoteName: "a", Index: idx}}).Index) {
|
||||
t.Fatal("merged index must be byte-stable across merges")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApkIndexTextSkipsSignature(t *testing.T) {
|
||||
signed := append(apkTarGz(t, ".SIGN.RSA.alpine.rsa.pub", []byte("sig"), false), apkTarGz(t, "APKINDEX", []byte(apkRec("foo", "1", "x86_64")), true)...)
|
||||
idx, err := apkIndexText(signed)
|
||||
if err != nil || !strings.Contains(string(idx), "P:foo\n") {
|
||||
t.Fatalf("signed index: %v\n%s", err, idx)
|
||||
}
|
||||
if _, err := apkIndexText(apkTarGz(t, "DESCRIPTION", []byte("x"), true)); err == nil {
|
||||
t.Fatal("index without APKINDEX entry should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func alpineEngine(members map[string]string) *Engine {
|
||||
e := fakeEngine(nil)
|
||||
e.apkMember = func(_ context.Context, name, dir string) (*AlpineMember, error) {
|
||||
idx, ok := members[name]
|
||||
if !ok {
|
||||
return nil, errors.New("upstream down")
|
||||
}
|
||||
if dir != "x86_64/" {
|
||||
return &AlpineMember{RemoteName: name}, nil
|
||||
}
|
||||
return &AlpineMember{RemoteName: name, Index: []byte(idx)}, nil
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
func alpineVirt(members ...string) models.Virtual {
|
||||
return models.Virtual{Name: "v", PackageType: models.PackageAlpine, Members: members}
|
||||
}
|
||||
|
||||
func TestFetchAlpineIndexAndRedirect(t *testing.T) {
|
||||
e := alpineEngine(map[string]string{"loc": apkRec("foo", "1-r0", "x86_64"), "gh": apkRec("foo", "1-r0", "x86_64") + "\n" + apkRec("tool", "2-r0", "x86_64")})
|
||||
virt := alpineVirt("loc", "gh")
|
||||
|
||||
body, ct, err := e.Fetch(context.Background(), virt, "./x86_64/APKINDEX.tar.gz", "")
|
||||
if err != nil || ct != "application/gzip" {
|
||||
t.Fatalf("fetch index: %v %q", err, ct)
|
||||
}
|
||||
if idx, _ := apkIndexText(body); strings.Count(string(idx), "P:") != 2 {
|
||||
t.Fatalf("merged index:\n%s", idx)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
path, want string
|
||||
ok bool
|
||||
}{
|
||||
{path: "x86_64/foo-1-r0.apk", ok: true, want: "http://h/api/v1/remote/loc/x86_64/foo-1-r0.apk"},
|
||||
{path: "./x86_64/tool-2-r0.apk", ok: true, want: "http://h/api/v1/remote/gh/x86_64/tool-2-r0.apk"},
|
||||
{path: "x86_64/../x86_64/tool-2-r0.apk", ok: true, want: "http://h/api/v1/remote/gh/x86_64/tool-2-r0.apk"},
|
||||
{path: "x86_64/missing-1-r0.apk"},
|
||||
{path: "aarch64/foo-1-r0.apk"},
|
||||
{path: "x86_64/APKINDEX.tar.gz"},
|
||||
{path: "foo-1-r0.apk"},
|
||||
} {
|
||||
got, ok, err := e.MemberRedirect(context.Background(), virt, tc.path, "", "http://h/")
|
||||
if got != tc.want || ok != tc.ok || err != nil {
|
||||
t.Errorf("%s: got (%q, %v, %v), want (%q, %v)", tc.path, got, ok, err, tc.want, tc.ok)
|
||||
}
|
||||
}
|
||||
for _, p := range []string{"x86_64/missing-1-r0.apk", "x86_64/DESCRIPTION", "APKINDEX.tar.gz"} {
|
||||
if _, _, err := e.Fetch(context.Background(), virt, p, ""); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("%s: want not found, got %v", p, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchAlpineFailsClosed(t *testing.T) {
|
||||
e := alpineEngine(map[string]string{"a": apkRec("foo", "1-r0", "x86_64")})
|
||||
virt := alpineVirt("a", "down")
|
||||
if _, _, err := e.Fetch(context.Background(), virt, "x86_64/APKINDEX.tar.gz", ""); err == nil || errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("index with a member down: want upstream error, got %v", err)
|
||||
}
|
||||
if _, ok, err := e.MemberRedirect(context.Background(), virt, "x86_64/foo-1-r0.apk", "", "http://h/"); ok || err != nil {
|
||||
t.Fatalf("redirect with a member down must defer to Fetch: ok %v err %v", ok, err)
|
||||
}
|
||||
if _, _, err := e.Fetch(context.Background(), virt, "x86_64/foo-1-r0.apk", ""); err == nil || errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("download with a member down: want upstream error, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package virtual
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"path"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// debIndexRe matches the binary Packages indexes a virtual merges: the flat
|
||||
// repo's root Packages and a suite's <component>/binary-<arch>/Packages.
|
||||
var debIndexRe = regexp.MustCompile(`^([^/]+/binary-[^/]+/)?Packages(\.[a-z0-9]+)?$`)
|
||||
|
||||
// debByHashRe matches an index requested by its SHA256 (Acquire-By-Hash).
|
||||
var debByHashRe = regexp.MustCompile(`^([^/]+/binary-[^/]+/)?by-hash/SHA256/[0-9a-f]{64}$`)
|
||||
|
||||
// debCompressions is the order a member's index variants are fetched in.
|
||||
var debCompressions = []string{".xz", ".gz", ".zst", ".bz2", ""}
|
||||
|
||||
// DebMember is one member's Release plus its decompressed Packages indexes,
|
||||
// keyed by index directory relative to the Release ("" for a flat repo).
|
||||
type DebMember struct {
|
||||
RemoteName string
|
||||
Release []byte
|
||||
Packages map[string][]byte
|
||||
}
|
||||
|
||||
// DebRepo is a merged apt repo: Release plus the indexes it lists, keyed by
|
||||
// their path relative to the Release.
|
||||
type DebRepo struct {
|
||||
Release []byte
|
||||
Files map[string][]byte
|
||||
}
|
||||
|
||||
type debRelease struct {
|
||||
fields map[string]string
|
||||
files []string
|
||||
}
|
||||
|
||||
// parseDebRelease reads a Release file's single-line fields and the names
|
||||
// listed in its checksum sections.
|
||||
func parseDebRelease(body []byte) debRelease {
|
||||
rel := debRelease{fields: map[string]string{}}
|
||||
inSums := false
|
||||
seen := map[string]bool{}
|
||||
for line := range strings.SplitSeq(string(body), "\n") {
|
||||
line = strings.TrimRight(line, "\r")
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if line[0] == ' ' || line[0] == '\t' {
|
||||
if f := strings.Fields(line); inSums && len(f) == 3 && !seen[f[2]] {
|
||||
seen[f[2]] = true
|
||||
rel.files = append(rel.files, f[2])
|
||||
}
|
||||
continue
|
||||
}
|
||||
k, v, _ := strings.Cut(line, ":")
|
||||
v = strings.TrimSpace(v)
|
||||
inSums = v == "" && (k == "MD5Sum" || strings.HasPrefix(k, "SHA"))
|
||||
if _, ok := rel.fields[k]; !ok {
|
||||
rel.fields[k] = v
|
||||
}
|
||||
}
|
||||
return rel
|
||||
}
|
||||
|
||||
// debIndexFiles returns, per index directory, the variant of its Packages file
|
||||
// to fetch from a member's Release listing.
|
||||
func debIndexFiles(rel debRelease) map[string]string {
|
||||
listed := map[string]bool{}
|
||||
for _, f := range rel.files {
|
||||
listed[f] = true
|
||||
}
|
||||
out := map[string]string{}
|
||||
for _, f := range rel.files {
|
||||
m := debIndexRe.FindStringSubmatch(f)
|
||||
if m == nil || out[m[1]] != "" {
|
||||
continue
|
||||
}
|
||||
for _, c := range debCompressions {
|
||||
if listed[m[1]+"Packages"+c] {
|
||||
out[m[1]] = m[1] + "Packages" + c
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func splitStanzas(body []byte) []string {
|
||||
var out []string
|
||||
for s := range strings.SplitSeq(strings.ReplaceAll(string(body), "\r\n", "\n"), "\n\n") {
|
||||
if s = strings.Trim(s, "\n"); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func stanzaField(stanza, key string) string {
|
||||
for line := range strings.SplitSeq(stanza, "\n") {
|
||||
if k, v, ok := strings.Cut(line, ":"); ok && k == key {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func unionFields(dst []string, field string) []string {
|
||||
for f := range strings.FieldsSeq(field) {
|
||||
if !slices.Contains(dst, f) {
|
||||
dst = append(dst, f)
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
func parseDebDate(s string) (time.Time, bool) {
|
||||
for _, layout := range []string{time.RFC1123Z, time.RFC1123} {
|
||||
if t, err := time.Parse(layout, s); err == nil {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
// MergeDeb merges members into one apt repo. Members are in priority order: the
|
||||
// first member to carry a (Package, Version, Architecture) in an index wins;
|
||||
// stanzas missing any of those are always kept. Filename is prefixed with the
|
||||
// owning member's name so the virtual can route downloads back to it.
|
||||
func MergeDeb(members []DebMember) *DebRepo {
|
||||
var suite, codename string
|
||||
var arches, comps, dirs []string
|
||||
date := time.Unix(0, 0)
|
||||
for _, m := range members {
|
||||
rel := parseDebRelease(m.Release)
|
||||
if suite == "" {
|
||||
suite, codename = rel.fields["Suite"], rel.fields["Codename"]
|
||||
}
|
||||
arches = unionFields(arches, rel.fields["Architectures"])
|
||||
comps = unionFields(comps, rel.fields["Components"])
|
||||
if t, ok := parseDebDate(rel.fields["Date"]); ok && t.After(date) {
|
||||
date = t
|
||||
}
|
||||
for d := range m.Packages {
|
||||
if !slices.Contains(dirs, d) {
|
||||
dirs = append(dirs, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
slices.Sort(dirs)
|
||||
|
||||
repo := &DebRepo{Files: map[string][]byte{}}
|
||||
for _, d := range dirs {
|
||||
seen := map[string]bool{}
|
||||
var b bytes.Buffer
|
||||
for _, m := range members {
|
||||
for _, s := range splitStanzas(m.Packages[d]) {
|
||||
pkg, ver, arch := stanzaField(s, "Package"), stanzaField(s, "Version"), stanzaField(s, "Architecture")
|
||||
if pkg != "" && ver != "" && arch != "" {
|
||||
key := pkg + "\x00" + ver + "\x00" + arch
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
}
|
||||
if fn := stanzaField(s, "Filename"); fn != "" {
|
||||
s = rewriteFilename(s, m.RemoteName+"/"+strings.TrimPrefix(path.Clean("/"+fn), "/"))
|
||||
}
|
||||
b.WriteString(s)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
}
|
||||
gz := gzipDeterministic(b.Bytes())
|
||||
repo.Files[d+"Packages"] = b.Bytes()
|
||||
repo.Files[d+"Packages.gz"] = gz
|
||||
repo.Files[d+"by-hash/SHA256/"+sha256Hex(b.Bytes())] = b.Bytes()
|
||||
repo.Files[d+"by-hash/SHA256/"+sha256Hex(gz)] = gz
|
||||
}
|
||||
|
||||
var r bytes.Buffer
|
||||
if suite != "" {
|
||||
fmt.Fprintf(&r, "Suite: %s\n", suite)
|
||||
}
|
||||
if codename != "" {
|
||||
fmt.Fprintf(&r, "Codename: %s\n", codename)
|
||||
}
|
||||
fmt.Fprintf(&r, "Date: %s\n", date.UTC().Format(time.RFC1123Z))
|
||||
fmt.Fprintf(&r, "Architectures: %s\n", strings.Join(arches, " "))
|
||||
if len(comps) > 0 {
|
||||
fmt.Fprintf(&r, "Components: %s\n", strings.Join(comps, " "))
|
||||
}
|
||||
r.WriteString("Acquire-By-Hash: yes\n")
|
||||
for _, sec := range []struct {
|
||||
name string
|
||||
sum func([]byte) string
|
||||
}{{"MD5Sum", md5Hex}, {"SHA256", sha256Hex}} {
|
||||
r.WriteString(sec.name + ":\n")
|
||||
for _, d := range dirs {
|
||||
for _, n := range []string{d + "Packages", d + "Packages.gz"} {
|
||||
fmt.Fprintf(&r, " %s %d %s\n", sec.sum(repo.Files[n]), len(repo.Files[n]), n)
|
||||
}
|
||||
}
|
||||
}
|
||||
repo.Release = r.Bytes()
|
||||
return repo
|
||||
}
|
||||
|
||||
func rewriteFilename(stanza, fn string) string {
|
||||
lines := strings.Split(stanza, "\n")
|
||||
for i, l := range lines {
|
||||
if strings.HasPrefix(l, "Filename:") {
|
||||
lines[i] = "Filename: " + fn
|
||||
break
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// debIndexPath splits an index request into its Release directory and the file
|
||||
// under it. ok is false for anything a deb virtual does not merge.
|
||||
func debIndexPath(p string) (base, name string, ok bool) {
|
||||
if rest, found := strings.CutPrefix(p, "dists/"); found {
|
||||
suite, n, found := strings.Cut(rest, "/")
|
||||
if !found || suite == "" {
|
||||
return "", "", false
|
||||
}
|
||||
base, p = "dists/"+suite+"/", n
|
||||
}
|
||||
if p != "Release" && !debIndexRe.MatchString(p) && !debByHashRe.MatchString(p) {
|
||||
return "", "", false
|
||||
}
|
||||
return base, p, true
|
||||
}
|
||||
|
||||
func md5Hex(data []byte) string {
|
||||
h := md5.Sum(data)
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package virtual
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"maps"
|
||||
"path"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func debStanza(pkg, ver, arch, filename string) string {
|
||||
return fmt.Sprintf("Package: %s\nVersion: %s\nArchitecture: %s\nDescription: test\n multi-line body\nFilename: %s\nSize: 1\n", pkg, ver, arch, filename)
|
||||
}
|
||||
|
||||
func flatDebMember(name, date string, stanzas ...string) DebMember {
|
||||
rel := "Date: " + date + "\nArchitectures: amd64\nAcquire-By-Hash: no\nSHA256:\n x 1 Packages\n x 1 Packages.gz\n"
|
||||
return DebMember{RemoteName: name, Release: []byte(rel), Packages: map[string][]byte{"": []byte(strings.Join(stanzas, "\n"))}}
|
||||
}
|
||||
|
||||
func TestMergeDebDedupesByMemberOrder(t *testing.T) {
|
||||
repo := MergeDeb([]DebMember{
|
||||
flatDebMember("a", "Mon, 05 Oct 2026 10:00:00 +0000",
|
||||
debStanza("foo", "1.0", "amd64", "pool/foo_1.0_amd64.deb"),
|
||||
debStanza("bar", "2.0", "amd64", "/pool/bar.deb"),
|
||||
debStanza("dot", "1.0", "amd64", "./dot.deb"),
|
||||
"Package: noversion\nFilename: nv1.deb\n",
|
||||
"Package: noversion\nFilename: nv2.deb\n"),
|
||||
flatDebMember("b", "Tue, 06 Oct 2026 10:00:00 UTC",
|
||||
debStanza("foo", "1.0", "amd64", "pool/foo-from-b.deb"),
|
||||
debStanza("foo", "1.0", "arm64", "pool/foo_arm64.deb"),
|
||||
debStanza("foo", "1.1", "amd64", "o/r/releases/download/v1/foo.deb")),
|
||||
})
|
||||
pk := string(repo.Files["Packages"])
|
||||
stanzas := splitStanzas(repo.Files["Packages"])
|
||||
if len(stanzas) != 7 {
|
||||
t.Fatalf("want 7 stanzas (one duplicate dropped), got %d:\n%s", len(stanzas), pk)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Filename: a/pool/foo_1.0_amd64.deb",
|
||||
"Filename: a/pool/bar.deb",
|
||||
"Filename: a/dot.deb",
|
||||
"Filename: a/nv1.deb",
|
||||
"Filename: a/nv2.deb",
|
||||
"Filename: b/pool/foo_arm64.deb",
|
||||
"Filename: b/o/r/releases/download/v1/foo.deb",
|
||||
" multi-line body",
|
||||
} {
|
||||
if !strings.Contains(pk, want) {
|
||||
t.Errorf("merged Packages missing %q:\n%s", want, pk)
|
||||
}
|
||||
}
|
||||
if strings.Contains(pk, "foo-from-b") {
|
||||
t.Fatalf("second member's duplicate must lose:\n%s", pk)
|
||||
}
|
||||
if !bytes.Equal(gunzip(t, repo.Files["Packages.gz"]), repo.Files["Packages"]) {
|
||||
t.Fatal("Packages.gz does not match Packages")
|
||||
}
|
||||
if !strings.Contains(string(repo.Release), "Date: Tue, 06 Oct 2026 10:00:00 +0000\n") {
|
||||
t.Fatalf("Date should be the newest member date:\n%s", repo.Release)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeDebReleaseHashesFiles(t *testing.T) {
|
||||
repo := MergeDeb([]DebMember{flatDebMember("a", "", debStanza("foo", "1", "amd64", "pool/foo.deb"))})
|
||||
rel := parseDebRelease(repo.Release)
|
||||
if got := slices.Sorted(slices.Values(rel.files)); !slices.Equal(got, []string{"Packages", "Packages.gz"}) {
|
||||
t.Fatalf("Release lists %v", got)
|
||||
}
|
||||
inSHA := false
|
||||
checked := 0
|
||||
for line := range strings.SplitSeq(string(repo.Release), "\n") {
|
||||
if !strings.HasPrefix(line, " ") {
|
||||
inSHA = line == "SHA256:"
|
||||
continue
|
||||
}
|
||||
var sum, name string
|
||||
var size int
|
||||
if _, err := fmt.Sscan(line, &sum, &size, &name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := repo.Files[name]
|
||||
want := sha256Hex(body)
|
||||
if !inSHA {
|
||||
h := md5.Sum(body)
|
||||
want = hex.EncodeToString(h[:])
|
||||
}
|
||||
if sum != want || size != len(body) {
|
||||
t.Errorf("%s: Release has %s %d, file is %s %d", name, sum, size, want, len(body))
|
||||
}
|
||||
checked++
|
||||
}
|
||||
if checked != 4 {
|
||||
t.Fatalf("checked %d Release entries, want 4:\n%s", checked, repo.Release)
|
||||
}
|
||||
if !bytes.Equal(MergeDeb([]DebMember{flatDebMember("a", "", debStanza("foo", "1", "amd64", "pool/foo.deb"))}).Release, repo.Release) {
|
||||
t.Fatal("merge must be deterministic")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeDebSuiteLayout(t *testing.T) {
|
||||
rel := func(comps string) []byte {
|
||||
return []byte("Suite: stable\nCodename: trixie\nArchitectures: amd64 arm64\nComponents: " + comps + "\nSHA256:\n x 1 main/binary-amd64/Packages.xz\n")
|
||||
}
|
||||
repo := MergeDeb([]DebMember{
|
||||
{RemoteName: "deb1", Release: rel("main"), Packages: map[string][]byte{"main/binary-amd64/": []byte(debStanza("foo", "1", "amd64", "pool/main/f/foo.deb"))}},
|
||||
{RemoteName: "deb2", Release: rel("main contrib"), Packages: map[string][]byte{
|
||||
"main/binary-amd64/": []byte(debStanza("bar", "1", "amd64", "pool/main/b/bar.deb")),
|
||||
"contrib/binary-amd64/": []byte(debStanza("baz", "1", "amd64", "pool/contrib/b/baz.deb")),
|
||||
}},
|
||||
})
|
||||
for _, want := range []string{"Suite: stable\n", "Codename: trixie\n", "Components: main contrib\n", "Architectures: amd64 arm64\n"} {
|
||||
if !strings.Contains(string(repo.Release), want) {
|
||||
t.Errorf("Release missing %q:\n%s", want, repo.Release)
|
||||
}
|
||||
}
|
||||
want := []string{"contrib/binary-amd64/Packages", "contrib/binary-amd64/Packages.gz", "main/binary-amd64/Packages", "main/binary-amd64/Packages.gz"}
|
||||
if got := slices.Sorted(slices.Values(parseDebRelease(repo.Release).files)); !slices.Equal(got, want) {
|
||||
t.Fatalf("Release lists %v, want %v", got, want)
|
||||
}
|
||||
if !strings.Contains(string(repo.Release), "Acquire-By-Hash: yes\n") {
|
||||
t.Fatalf("Release must advertise by-hash:\n%s", repo.Release)
|
||||
}
|
||||
for _, n := range want {
|
||||
byHash := path.Dir(n) + "/by-hash/SHA256/" + sha256Hex(repo.Files[n])
|
||||
if !bytes.Equal(repo.Files[byHash], repo.Files[n]) {
|
||||
t.Errorf("%s not served at %s", n, byHash)
|
||||
}
|
||||
}
|
||||
if len(repo.Files) != 2*len(want) {
|
||||
t.Fatalf("files %v, want each index plus its by-hash copy", slices.Sorted(maps.Keys(repo.Files)))
|
||||
}
|
||||
if n := len(splitStanzas(repo.Files["main/binary-amd64/Packages"])); n != 2 {
|
||||
t.Fatalf("main/binary-amd64 should merge both members, got %d stanzas", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDebIndexFiles(t *testing.T) {
|
||||
rel := parseDebRelease([]byte("Origin: Debian\nMD5Sum:\n a 1 main/binary-amd64/Packages\n a 1 main/binary-amd64/Packages.gz\nSHA256:\n" +
|
||||
" a 1 main/binary-amd64/Packages\n a 1 main/binary-amd64/Packages.xz\n a 1 main/binary-amd64/Packages.gz\n" +
|
||||
" a 1 main/debian-installer/binary-amd64/Packages.xz\n a 1 main/i18n/Translation-en.xz\n a 1 contrib/binary-all/Packages\n"))
|
||||
got := debIndexFiles(rel)
|
||||
want := map[string]string{"main/binary-amd64/": "main/binary-amd64/Packages.xz", "contrib/binary-all/": "contrib/binary-all/Packages"}
|
||||
if !maps.Equal(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
if flat := debIndexFiles(parseDebRelease([]byte("SHA256:\n a 1 Packages\n a 1 Packages.gz\n"))); flat[""] != "Packages.gz" {
|
||||
t.Fatalf("flat repo should fetch Packages.gz, got %v", flat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDebIndexPath(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
in, base, name string
|
||||
ok bool
|
||||
}{
|
||||
{"Release", "", "Release", true},
|
||||
{"Packages.gz", "", "Packages.gz", true},
|
||||
{"dists/trixie/Release", "dists/trixie/", "Release", true},
|
||||
{"dists/trixie/main/binary-amd64/Packages", "dists/trixie/", "main/binary-amd64/Packages", true},
|
||||
{"by-hash/SHA256/abababababababababababababababababababababababababababababababab", "", "by-hash/SHA256/abababababababababababababababababababababababababababababababab", true},
|
||||
{"dists/trixie/main/binary-amd64/by-hash/SHA256/abababababababababababababababababababababababababababababababab", "dists/trixie/", "main/binary-amd64/by-hash/SHA256/abababababababababababababababababababababababababababababababab", true},
|
||||
{"dists/trixie/main/binary-amd64/by-hash/MD5Sum/abababababababababababababababab", "", "", false},
|
||||
{"InRelease", "", "", false},
|
||||
{"Release.gpg", "", "", false},
|
||||
{"dists/trixie/InRelease", "", "", false},
|
||||
{"dists/trixie/main/i18n/Translation-en", "", "", false},
|
||||
{"dists/Release", "", "", false},
|
||||
{"pool/foo.deb", "", "", false},
|
||||
} {
|
||||
base, name, ok := debIndexPath(tc.in)
|
||||
if base != tc.base || name != tc.name || ok != tc.ok {
|
||||
t.Errorf("%s: got (%q, %q, %v), want (%q, %q, %v)", tc.in, base, name, ok, tc.base, tc.name, tc.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
+191
-10
@@ -9,6 +9,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -26,6 +27,8 @@ type Engine struct {
|
||||
proxyEngine *proxy.Engine
|
||||
getRemote func(context.Context, string) (*models.Remote, error)
|
||||
rpmMember func(context.Context, string) (*RPMMember, error)
|
||||
debMember func(ctx context.Context, name, base string) (*DebMember, error)
|
||||
apkMember func(ctx context.Context, name, dir string) (*AlpineMember, error)
|
||||
npmMember func(ctx context.Context, name, pkg string) (*NPMMember, error)
|
||||
|
||||
// NPMLocal returns a local npm repo's packument with tarball paths relative
|
||||
@@ -45,6 +48,8 @@ type Engine struct {
|
||||
|
||||
goMember func(ctx context.Context, name, path string) (*GoMember, error)
|
||||
|
||||
puppetDoc func(ctx context.Context, remote models.Remote, path string) ([]byte, error)
|
||||
|
||||
mergeTTL time.Duration
|
||||
|
||||
sf singleflight.Group
|
||||
@@ -60,29 +65,43 @@ type mergedRepo interface {
|
||||
|
||||
func (r *RPMRepo) index() []byte { return r.Repomd }
|
||||
func (r *RPMRepo) files() map[string][]byte { return r.Files }
|
||||
func (r *DebRepo) index() []byte { return r.Release }
|
||||
func (r *DebRepo) files() map[string][]byte { return r.Files }
|
||||
|
||||
// repoGen holds a virtual's current merge plus the previous one, so a client
|
||||
// holding the previous index can still fetch the files it references.
|
||||
type repoGen struct {
|
||||
cur, prev mergedRepo
|
||||
at time.Time
|
||||
at, used time.Time
|
||||
}
|
||||
|
||||
const rpmMergeTTL = 60 * time.Second
|
||||
|
||||
// maxDebSuites bounds the client-chosen deb suites or alpine directories
|
||||
// cached per virtual.
|
||||
const maxDebSuites = 32
|
||||
|
||||
func NewEngine(db *database.DB, proxyEngine *proxy.Engine) *Engine {
|
||||
e := &Engine{db: db, proxyEngine: proxyEngine, getRemote: db.GetRemote, mergeTTL: rpmMergeTTL}
|
||||
e.rpmMember = e.fetchRPMMember
|
||||
e.debMember = e.fetchDebMember
|
||||
e.apkMember = e.fetchAlpineMember
|
||||
e.npmMember = e.fetchNPMMember
|
||||
e.gemsMember = e.fetchGemsMember
|
||||
e.ansibleDoc = e.ansibleMemberDoc
|
||||
e.goMember = e.fetchGoMember
|
||||
e.puppetDoc = e.fetchMemberPath
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *Engine) Fetch(ctx context.Context, virt models.Virtual, path string, proxyBaseURL string) ([]byte, string, error) {
|
||||
if virt.PackageType == models.PackageRPM {
|
||||
switch virt.PackageType {
|
||||
case models.PackageRPM:
|
||||
return e.fetchRPM(ctx, virt, path)
|
||||
case models.PackageDeb:
|
||||
return e.fetchDeb(ctx, virt, path)
|
||||
case models.PackageAlpine:
|
||||
return e.fetchAlpine(ctx, virt, path)
|
||||
}
|
||||
|
||||
merger, err := GetMerger(virt.PackageType)
|
||||
@@ -201,16 +220,24 @@ var (
|
||||
ErrBadPath = errors.New("invalid path")
|
||||
)
|
||||
|
||||
// MemberRedirect maps an rpm, npm or ansible virtual package path
|
||||
// (<member>/<href>, as written by MergeRPM/MergeNPM/AnsibleVersion) to the
|
||||
// owning member's route. ok is false for any other path; ErrBadPath means the
|
||||
// path is absolute or escapes the member.
|
||||
// MemberRedirect maps an rpm, deb, npm or ansible virtual package path
|
||||
// (<member>/<href>, as written by MergeRPM/MergeDeb/MergeNPM/AnsibleVersion),
|
||||
// or an alpine <dir>/<name>-<version>.apk, to the owning member's route. ok is false for any other path; ErrBadPath
|
||||
// means the path is absolute or escapes the member.
|
||||
func (e *Engine) MemberRedirect(ctx context.Context, virt models.Virtual, path, rawQuery, proxyBaseURL string) (string, bool, error) {
|
||||
switch virt.PackageType {
|
||||
case models.PackageRPM:
|
||||
if strings.HasPrefix(path, "repodata/") {
|
||||
return "", false, nil
|
||||
}
|
||||
case models.PackageDeb:
|
||||
// apt requests a flat repo's files under its "./" dist verbatim.
|
||||
path = strings.TrimPrefix(path, "./")
|
||||
if strings.HasPrefix(path, "dists/") {
|
||||
return "", false, nil
|
||||
}
|
||||
case models.PackageAlpine:
|
||||
return e.alpineRedirect(ctx, virt, path, rawQuery, proxyBaseURL)
|
||||
case models.PackageNPM, models.PackageAnsible:
|
||||
default:
|
||||
return "", false, nil
|
||||
@@ -291,6 +318,7 @@ func (e *Engine) merged(ctx context.Context, key string, merge func(context.Cont
|
||||
e.mu.Lock()
|
||||
g := e.gens[key]
|
||||
if g != nil && time.Since(g.at) < e.mergeTTL {
|
||||
g.used = time.Now()
|
||||
e.mu.Unlock()
|
||||
return g.cur, nil
|
||||
}
|
||||
@@ -309,12 +337,12 @@ func (e *Engine) merged(ctx context.Context, key string, merge func(context.Cont
|
||||
g := e.gens[key]
|
||||
switch {
|
||||
case g == nil:
|
||||
e.gens[key] = &repoGen{cur: repo, at: time.Now()}
|
||||
e.gens[key] = &repoGen{cur: repo, at: time.Now(), used: time.Now()}
|
||||
case string(g.cur.index()) == string(repo.index()):
|
||||
g.at = time.Now()
|
||||
g.at, g.used = time.Now(), time.Now()
|
||||
repo = g.cur
|
||||
default:
|
||||
g.prev, g.cur, g.at = g.cur, repo, time.Now()
|
||||
g.prev, g.cur, g.at, g.used = g.cur, repo, time.Now(), time.Now()
|
||||
}
|
||||
return repo, nil
|
||||
})
|
||||
@@ -361,6 +389,157 @@ func fetchMembers[T any](virt models.Virtual, fetch func(name string) (*T, error
|
||||
return members, nil
|
||||
}
|
||||
|
||||
func (e *Engine) fetchDeb(ctx context.Context, virt models.Virtual, reqPath string) ([]byte, string, error) {
|
||||
base, name, ok := debIndexPath(strings.TrimPrefix(path.Clean("/"+reqPath), "/"))
|
||||
if !ok {
|
||||
return nil, "", ErrNotFound
|
||||
}
|
||||
key := "deb\x00" + virt.Name + "\x00" + base
|
||||
if name != "Release" {
|
||||
if body, ok := e.cachedFile(key, name); ok {
|
||||
return body, debContentType(name), nil
|
||||
}
|
||||
}
|
||||
|
||||
repo, err := e.merged(ctx, key, func(ctx context.Context) (mergedRepo, error) {
|
||||
members, err := fetchMembers(virt, func(name string) (*DebMember, error) { return e.debMember(ctx, name, base) })
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return MergeDeb(members), nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
e.evictLRU("deb\x00"+virt.Name+"\x00", maxDebSuites)
|
||||
if name == "Release" {
|
||||
return repo.index(), "text/plain", nil
|
||||
}
|
||||
if body, ok := repo.files()[name]; ok {
|
||||
return body, debContentType(name), nil
|
||||
}
|
||||
return nil, "", ErrNotFound
|
||||
}
|
||||
|
||||
func debContentType(name string) string {
|
||||
if strings.HasSuffix(name, ".gz") {
|
||||
return "application/gzip"
|
||||
}
|
||||
return "text/plain"
|
||||
}
|
||||
|
||||
// fetchDebMember reads a member's Release under base and each binary Packages
|
||||
// index it lists.
|
||||
func (e *Engine) fetchDebMember(ctx context.Context, name, base string) (*DebMember, error) {
|
||||
remote, err := e.getRemote(ctx, name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("remote %q: %w", name, err)
|
||||
}
|
||||
rel, err := e.fetchMemberPath(ctx, *remote, base+"Release")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m := &DebMember{RemoteName: name, Release: rel, Packages: map[string][]byte{}}
|
||||
for dir, file := range debIndexFiles(parseDebRelease(rel)) {
|
||||
raw, err := e.fetchMemberPath(ctx, *remote, base+file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if m.Packages[dir], err = decompress(file, raw); err != nil {
|
||||
return nil, fmt.Errorf("decompress %s: %w", file, err)
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (e *Engine) mergedAlpine(ctx context.Context, virt models.Virtual, dir string) (mergedRepo, error) {
|
||||
repo, err := e.merged(ctx, "alpine\x00"+virt.Name+"\x00"+dir, func(ctx context.Context) (mergedRepo, error) {
|
||||
members, err := fetchMembers(virt, func(name string) (*AlpineMember, error) { return e.apkMember(ctx, name, dir) })
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return MergeAlpine(members), nil
|
||||
})
|
||||
if err == nil {
|
||||
e.evictLRU("alpine\x00"+virt.Name+"\x00", maxDebSuites)
|
||||
}
|
||||
return repo, err
|
||||
}
|
||||
|
||||
func (e *Engine) fetchAlpine(ctx context.Context, virt models.Virtual, reqPath string) ([]byte, string, error) {
|
||||
dir, file, ok := alpinePath(strings.TrimPrefix(path.Clean("/"+reqPath), "/"))
|
||||
if !ok {
|
||||
return nil, "", ErrNotFound
|
||||
}
|
||||
repo, err := e.mergedAlpine(ctx, virt, dir)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if file != "APKINDEX.tar.gz" {
|
||||
return nil, "", ErrNotFound
|
||||
}
|
||||
return repo.index(), "application/gzip", nil
|
||||
}
|
||||
|
||||
// alpineRedirect finds the member whose index lists the requested .apk; apk
|
||||
// builds the download path itself, so the index cannot carry a member prefix.
|
||||
// A failed merge returns ok=false so Fetch re-merges and fails with 502.
|
||||
func (e *Engine) alpineRedirect(ctx context.Context, virt models.Virtual, reqPath, rawQuery, proxyBaseURL string) (string, bool, error) {
|
||||
dir, file, ok := alpinePath(strings.TrimPrefix(path.Clean("/"+reqPath), "/"))
|
||||
if !ok || file == "APKINDEX.tar.gz" {
|
||||
return "", false, nil
|
||||
}
|
||||
owner, ok := e.cachedFile("alpine\x00"+virt.Name+"\x00"+dir, file)
|
||||
if !ok {
|
||||
repo, err := e.mergedAlpine(ctx, virt, dir)
|
||||
if err != nil {
|
||||
return "", false, nil
|
||||
}
|
||||
if owner, ok = repo.files()[file]; !ok {
|
||||
return "", false, nil
|
||||
}
|
||||
}
|
||||
return e.memberLocation(ctx, string(owner), dir+file, rawQuery, proxyBaseURL)
|
||||
}
|
||||
|
||||
// fetchAlpineMember reads a member's APKINDEX under dir; a member without that
|
||||
// directory contributes nothing.
|
||||
func (e *Engine) fetchAlpineMember(ctx context.Context, name, dir string) (*AlpineMember, error) {
|
||||
remote, err := e.getRemote(ctx, name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("remote %q: %w", name, err)
|
||||
}
|
||||
raw, err := e.fetchMemberPath(ctx, *remote, dir+"APKINDEX.tar.gz")
|
||||
var pe *proxy.ProxyError
|
||||
if errors.Is(err, ErrNotFound) || errors.As(err, &pe) && pe.Status == http.StatusNotFound {
|
||||
return &AlpineMember{RemoteName: name}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
idx, err := apkIndexText(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s/%sAPKINDEX.tar.gz: %w", name, dir, err)
|
||||
}
|
||||
return &AlpineMember{RemoteName: name, Index: idx}, nil
|
||||
}
|
||||
|
||||
// evictLRU drops the least recently used merges under prefix beyond keep.
|
||||
func (e *Engine) evictLRU(prefix string, keep int) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
var keys []string
|
||||
for k := range e.gens {
|
||||
if strings.HasPrefix(k, prefix) {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
slices.SortFunc(keys, func(a, b string) int { return e.gens[b].used.Compare(e.gens[a].used) })
|
||||
for _, k := range keys[min(len(keys), keep):] {
|
||||
delete(e.gens, k)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) cachedFile(key, path string) ([]byte, bool) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
@@ -373,6 +552,7 @@ func (e *Engine) cachedFile(key, path string) ([]byte, bool) {
|
||||
continue
|
||||
}
|
||||
if body, ok := r.files()[path]; ok {
|
||||
g.used = time.Now()
|
||||
return body, true
|
||||
}
|
||||
}
|
||||
@@ -419,8 +599,9 @@ func (e *Engine) fetchMemberPath(ctx context.Context, remote models.Remote, path
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("provider %q does not serve a local index", remote.PackageType)
|
||||
}
|
||||
reqPath, _, _ := strings.Cut(path, "?")
|
||||
serve = func(w http.ResponseWriter, r *http.Request) bool {
|
||||
return indexer.ServeLocalIndex(w, r, e.db, remote.Name, path)
|
||||
return indexer.ServeLocalIndex(w, r, e.db, remote.Name, reqPath)
|
||||
}
|
||||
} else if rs, ok := prov.(provider.RemoteServer); ok {
|
||||
serve = func(w http.ResponseWriter, r *http.Request) bool {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
@@ -157,6 +158,97 @@ func TestMemberRedirect(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func debEngine(members map[string]*DebMember) *Engine {
|
||||
e := fakeEngine(nil)
|
||||
e.debMember = func(_ context.Context, name, base string) (*DebMember, error) {
|
||||
if m := members[name]; m != nil && base == "" {
|
||||
return m, nil
|
||||
}
|
||||
return nil, errors.New("upstream down")
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
func debVirt(members ...string) models.Virtual {
|
||||
return models.Virtual{Name: "v", PackageType: models.PackageDeb, Members: members}
|
||||
}
|
||||
|
||||
func TestFetchDebFailsClosed(t *testing.T) {
|
||||
a := flatDebMember("a", "", debStanza("foo", "1", "amd64", "pool/foo.deb"))
|
||||
e := debEngine(map[string]*DebMember{"a": &a})
|
||||
for _, p := range []string{"Release", "./Packages.gz", "dists/trixie/Release"} {
|
||||
if _, _, err := e.Fetch(context.Background(), debVirt("a", "b"), p, ""); err == nil || errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("%s: want upstream error with a member down, got %v", p, err)
|
||||
}
|
||||
}
|
||||
for _, p := range []string{"InRelease", "./Release.gpg", "Packages.xz"} {
|
||||
if _, _, err := e.Fetch(context.Background(), debVirt("a"), p, ""); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("%s: want not found, got %v", p, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchDebRemergesAfterTTL(t *testing.T) {
|
||||
a := flatDebMember("a", "", debStanza("foo", "1", "amd64", "pool/foo_1.deb"))
|
||||
e := debEngine(map[string]*DebMember{"a": &a})
|
||||
virt := debVirt("a")
|
||||
|
||||
rel, _, err := e.Fetch(context.Background(), virt, "./Release", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.Packages[""] = []byte(debStanza("foo", "2", "amd64", "pool/foo_2.deb"))
|
||||
e.mergeTTL = 0
|
||||
newRel, _, err := e.Fetch(context.Background(), virt, "Release", "")
|
||||
if err != nil || string(newRel) == string(rel) {
|
||||
t.Fatalf("Release should reflect the member change (err %v)", err)
|
||||
}
|
||||
pk, ct, err := e.Fetch(context.Background(), virt, "./Packages", "")
|
||||
if err != nil || ct != "text/plain" || !strings.Contains(string(pk), "Version: 2") {
|
||||
t.Fatalf("Packages should be the current merge (ct %q, err %v):\n%s", ct, err, pk)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchDebMergeIsSharedPerSuite(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
e := fakeEngine(nil)
|
||||
e.mergeTTL = time.Minute
|
||||
e.debMember = func(_ context.Context, name, base string) (*DebMember, error) {
|
||||
calls.Add(1)
|
||||
m := flatDebMember(name, "", debStanza("foo", "1", "amd64", "pool/foo.deb"))
|
||||
return &m, nil
|
||||
}
|
||||
for _, p := range []string{"Release", "Packages", "Packages.gz", "./Release", "dists/a/Release", "dists/b/Release"} {
|
||||
if _, _, err := e.Fetch(context.Background(), debVirt("a"), p, ""); err != nil && !errors.Is(err, ErrNotFound) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if n := calls.Load(); n != 3 {
|
||||
t.Fatalf("member fetched %d times, want once per Release dir (3)", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemberRedirectDeb(t *testing.T) {
|
||||
e := fakeEngine(nil)
|
||||
virt := debVirt("gh", "loc")
|
||||
for _, tc := range []struct {
|
||||
path, want string
|
||||
ok bool
|
||||
err error
|
||||
}{
|
||||
{path: "gh/o/r/releases/download/v1/foo_1.0_amd64.deb", ok: true, want: "http://h/api/v1/remote/gh/o/r/releases/download/v1/foo_1.0_amd64.deb"},
|
||||
{path: "./gh/pool/a+b.deb", ok: true, want: "http://h/api/v1/remote/gh/pool/a+b.deb"},
|
||||
{path: "gh/../loc/x.deb", err: ErrBadPath},
|
||||
{path: "dists/trixie/Release"},
|
||||
{path: "Packages"},
|
||||
} {
|
||||
got, ok, err := e.MemberRedirect(context.Background(), virt, tc.path, "", "http://h/")
|
||||
if got != tc.want || ok != tc.ok || !errors.Is(err, tc.err) {
|
||||
t.Errorf("%s: got (%q, %v, %v), want (%q, %v, %v)", tc.path, got, ok, err, tc.want, tc.ok, tc.err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type fakeRepo struct {
|
||||
idx string
|
||||
fs map[string][]byte
|
||||
@@ -206,3 +298,72 @@ func TestMergedReusesWithinTTLAndIsolatesKeys(t *testing.T) {
|
||||
t.Error("unknown key served a file")
|
||||
}
|
||||
}
|
||||
|
||||
func suiteDebMember(name, version string) *DebMember {
|
||||
return &DebMember{RemoteName: name, Release: []byte("Suite: s\nSHA256:\n x 1 main/binary-amd64/Packages.gz\n"),
|
||||
Packages: map[string][]byte{"main/binary-amd64/": []byte(debStanza("foo", version, "amd64", "pool/foo_"+version+".deb"))}}
|
||||
}
|
||||
|
||||
func TestFetchDebByHashSurvivesRemerge(t *testing.T) {
|
||||
version := "1"
|
||||
e := fakeEngine(nil)
|
||||
e.debMember = func(_ context.Context, name, _ string) (*DebMember, error) { return suiteDebMember(name, version), nil }
|
||||
virt := debVirt("a")
|
||||
fetch := func(p string) []byte {
|
||||
body, _, err := e.Fetch(context.Background(), virt, p, "")
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", p, err)
|
||||
}
|
||||
return body
|
||||
}
|
||||
byHash := func(rel []byte) string {
|
||||
m := regexp.MustCompile(`(?m)^SHA256:\n(?: .*\n)*? ([0-9a-f]{64}) \d+ main/binary-amd64/Packages.gz$`).FindSubmatch(rel)
|
||||
if m == nil {
|
||||
t.Fatalf("no Packages.gz SHA256 in Release:\n%s", rel)
|
||||
}
|
||||
return "dists/trixie/main/binary-amd64/by-hash/SHA256/" + string(m[1])
|
||||
}
|
||||
|
||||
oldRel := fetch("dists/trixie/Release")
|
||||
version, e.mergeTTL = "2", 0
|
||||
if newRel := fetch("dists/trixie/Release"); string(newRel) == string(oldRel) {
|
||||
t.Fatal("Release should reflect the member change")
|
||||
}
|
||||
if got := string(gunzip(t, fetch(byHash(oldRel)))); !strings.Contains(got, "Version: 1\n") {
|
||||
t.Fatalf("by-hash from the previous Release served the wrong index:\n%s", got)
|
||||
}
|
||||
if got := string(fetch("dists/trixie/main/binary-amd64/Packages")); !strings.Contains(got, "Version: 2\n") {
|
||||
t.Fatalf("fixed-name Packages should be the current merge:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchDebBoundsSuitesPerVirtual(t *testing.T) {
|
||||
e := fakeEngine(nil)
|
||||
e.mergeTTL = time.Minute
|
||||
e.debMember = func(_ context.Context, name, _ string) (*DebMember, error) { return suiteDebMember(name, "1"), nil }
|
||||
other := models.Virtual{Name: "other", PackageType: models.PackageDeb, Members: []string{"a"}}
|
||||
if _, _, err := e.Fetch(context.Background(), other, "dists/keep/Release", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := range maxDebSuites + 5 {
|
||||
if _, _, err := e.Fetch(context.Background(), debVirt("a"), fmt.Sprintf("dists/s%d/Release", i), ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
time.Sleep(time.Microsecond)
|
||||
}
|
||||
n := 0
|
||||
for k := range e.gens {
|
||||
if strings.HasPrefix(k, "deb\x00v\x00") {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n != maxDebSuites {
|
||||
t.Fatalf("virtual v caches %d suites, want %d", n, maxDebSuites)
|
||||
}
|
||||
if e.gens["deb\x00v\x00dists/s0/"] != nil || e.gens["deb\x00v\x00dists/s4/"] != nil {
|
||||
t.Fatal("least recently used suites should be evicted first")
|
||||
}
|
||||
if e.gens["deb\x00other\x00dists/keep/"] == nil {
|
||||
t.Fatal("another virtual's suites must not be evicted")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
package virtual
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/provider/puppet"
|
||||
"git.unkin.net/unkin/artifactapi/internal/proxy"
|
||||
"git.unkin.net/unkin/artifactapi/pkg/models"
|
||||
)
|
||||
|
||||
const (
|
||||
maxPuppetModules = 1024
|
||||
puppetMemberPage = 100
|
||||
maxPuppetMemberPages = 100
|
||||
)
|
||||
|
||||
// PuppetMember is one member's releases of a module; Found is false when the
|
||||
// member does not have it.
|
||||
type PuppetMember struct {
|
||||
RemoteName string
|
||||
Found bool
|
||||
Releases []map[string]any
|
||||
}
|
||||
|
||||
// PuppetDoc is a module's merged releases, newest version first, with
|
||||
// file_uri routed via the virtual; Owners maps each version to its member;
|
||||
// missing caches that no member has the module.
|
||||
type PuppetDoc struct {
|
||||
Releases []map[string]any
|
||||
Owners map[string]string
|
||||
body []byte
|
||||
missing bool
|
||||
}
|
||||
|
||||
func (d *PuppetDoc) index() []byte { return d.body }
|
||||
func (d *PuppetDoc) files() map[string][]byte { return nil }
|
||||
|
||||
// MergePuppetReleases merges member releases of slug; the earlier member wins
|
||||
// a duplicate version. A release whose file is not <slug>-<version>.tar.gz is
|
||||
// dropped, since its download could not be routed. ErrNotFound means no
|
||||
// member has the module.
|
||||
func MergePuppetReleases(slug string, members []PuppetMember) (*PuppetDoc, error) {
|
||||
d := &PuppetDoc{Releases: []map[string]any{}, Owners: map[string]string{}}
|
||||
found := false
|
||||
for _, m := range members {
|
||||
found = found || m.Found
|
||||
for _, r := range m.Releases {
|
||||
v, _ := r["version"].(string)
|
||||
file, _ := r["file_uri"].(string)
|
||||
name := path.Base(file)
|
||||
s, fv, ok := puppet.ParseRelease(strings.TrimSuffix(name, ".tar.gz"))
|
||||
if !ok || s != slug || fv != v || !strings.HasSuffix(name, ".tar.gz") {
|
||||
continue
|
||||
}
|
||||
if _, dup := d.Owners[v]; dup {
|
||||
continue
|
||||
}
|
||||
d.Owners[v] = m.RemoteName
|
||||
r["file_uri"] = "/v3/files/" + name
|
||||
d.Releases = append(d.Releases, r)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
slices.SortStableFunc(d.Releases, func(a, b map[string]any) int {
|
||||
return puppet.CompareSemver(b["version"].(string), a["version"].(string))
|
||||
})
|
||||
var err error
|
||||
d.body, err = json.Marshal(d.Releases)
|
||||
return d, err
|
||||
}
|
||||
|
||||
// PuppetModule is the /v3/modules/<slug> document for d: the newest release
|
||||
// as current_release and every release listed.
|
||||
func PuppetModule(d *PuppetDoc) (map[string]any, error) {
|
||||
if len(d.Releases) == 0 {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
top := d.Releases[0]
|
||||
ref, ok := top["module"].(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("release %v has no module", top["slug"])
|
||||
}
|
||||
mod := maps.Clone(ref)
|
||||
releases := make([]map[string]any, len(d.Releases))
|
||||
for i, r := range d.Releases {
|
||||
releases[i] = map[string]any{}
|
||||
for _, k := range []string{"uri", "slug", "version", "file_uri", "file_size", "supported", "created_at", "deleted_at"} {
|
||||
releases[i][k] = r[k]
|
||||
}
|
||||
}
|
||||
mod["current_release"] = top
|
||||
mod["releases"] = releases
|
||||
return mod, nil
|
||||
}
|
||||
|
||||
// PuppetReleases returns the merged releases of slug across members.
|
||||
func (e *Engine) PuppetReleases(ctx context.Context, virt models.Virtual, slug string) (*PuppetDoc, error) {
|
||||
prefix := "puppet\x00" + virt.Name + "\x00"
|
||||
inserted := false
|
||||
repo, err := e.merged(ctx, prefix+slug, func(ctx context.Context) (mergedRepo, error) {
|
||||
inserted = true
|
||||
members, err := fetchMembers(virt, func(m string) (*PuppetMember, error) { return e.puppetMember(ctx, m, slug) })
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d, err := MergePuppetReleases(slug, members)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return &PuppetDoc{missing: true}, nil
|
||||
}
|
||||
return d, err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if inserted {
|
||||
e.evictOldest(prefix, maxPuppetModules)
|
||||
}
|
||||
if d := repo.(*PuppetDoc); !d.missing {
|
||||
return d, nil
|
||||
}
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
// PuppetRedirect maps v3/releases/<slug>-<version> or v3/files/<file> to the
|
||||
// route of the member owning that release.
|
||||
func (e *Engine) PuppetRedirect(ctx context.Context, virt models.Virtual, p, proxyBaseURL string) (string, error) {
|
||||
rel, ok := strings.CutPrefix(p, "v3/releases/")
|
||||
if !ok {
|
||||
file, isFile := strings.CutPrefix(p, "v3/files/")
|
||||
if rel, ok = strings.CutSuffix(file, ".tar.gz"); !isFile || !ok {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
}
|
||||
slug, version, ok := puppet.ParseRelease(rel)
|
||||
if !ok {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
d, err := e.PuppetReleases(ctx, virt, slug)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
owner, ok := d.Owners[version]
|
||||
if !ok {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
loc, ok, err := e.memberLocation(ctx, owner, p, "", proxyBaseURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !ok {
|
||||
return "", fmt.Errorf("member %q not found", owner)
|
||||
}
|
||||
return loc, nil
|
||||
}
|
||||
|
||||
// puppetMember reads every release a member lists for slug, following its
|
||||
// pagination.
|
||||
func (e *Engine) puppetMember(ctx context.Context, member, slug string) (*PuppetMember, error) {
|
||||
remote, err := e.getRemote(ctx, member)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("remote %q: %w", member, err)
|
||||
}
|
||||
m := &PuppetMember{RemoteName: member}
|
||||
q := url.Values{"module": {slug}, "limit": {strconv.Itoa(puppetMemberPage)}, "exclude_fields": {"readme changelog license reference"}}
|
||||
for page := 0; page < maxPuppetMemberPages; page++ {
|
||||
if page > 0 {
|
||||
q.Set("offset", strconv.Itoa(len(m.Releases)))
|
||||
}
|
||||
p := "v3/releases?" + q.Encode()
|
||||
body, err := e.puppetDoc(ctx, *remote, p)
|
||||
var pe *proxy.ProxyError
|
||||
if errors.Is(err, ErrNotFound) || errors.As(err, &pe) && pe.Status == http.StatusNotFound {
|
||||
return m, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var doc struct {
|
||||
Pagination struct{ Total int }
|
||||
Results []map[string]any
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(body))
|
||||
dec.UseNumber()
|
||||
if err := dec.Decode(&doc); err != nil {
|
||||
return nil, fmt.Errorf("%s/%s: %v", member, p, err)
|
||||
}
|
||||
m.Found = m.Found || doc.Pagination.Total > 0
|
||||
m.Releases = append(m.Releases, doc.Results...)
|
||||
if len(m.Releases) >= doc.Pagination.Total {
|
||||
return m, nil
|
||||
}
|
||||
if len(doc.Results) == 0 {
|
||||
return nil, fmt.Errorf("%s/%s: empty page after %d of %d releases", member, p, len(m.Releases), doc.Pagination.Total)
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("%s: more than %d pages of %s releases", member, maxPuppetMemberPages, slug)
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package virtual
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/proxy"
|
||||
"git.unkin.net/unkin/artifactapi/pkg/models"
|
||||
)
|
||||
|
||||
func puppetRel(slug, version string) map[string]any {
|
||||
return map[string]any{
|
||||
"slug": slug + "-" + version, "version": version,
|
||||
"file_uri": "/v3/files/" + slug + "-" + version + ".tar.gz",
|
||||
"module": map[string]any{"slug": slug, "uri": "/v3/modules/" + slug},
|
||||
}
|
||||
}
|
||||
|
||||
func versionsOf(rs []map[string]any) string {
|
||||
var out []string
|
||||
for _, r := range rs {
|
||||
out = append(out, r["version"].(string))
|
||||
}
|
||||
return strings.Join(out, ",")
|
||||
}
|
||||
|
||||
func TestMergePuppetReleases(t *testing.T) {
|
||||
absolute := puppetRel("unkin-hello", "0.3.0")
|
||||
absolute["file_uri"] = "https://forge.example/api/v1/remote/forge/v3/files/unkin-hello-0.3.0.tar.gz"
|
||||
internalDup := puppetRel("unkin-hello", "0.2.0")
|
||||
internalDup["tag"] = "internal"
|
||||
wrongFile := puppetRel("unkin-hello", "9.0.0")
|
||||
wrongFile["file_uri"] = "/v3/files/unkin-other-9.0.0.tar.gz"
|
||||
|
||||
d, err := MergePuppetReleases("unkin-hello", []PuppetMember{
|
||||
{RemoteName: "internal", Found: true, Releases: []map[string]any{puppetRel("unkin-hello", "0.1.0"), internalDup}},
|
||||
{RemoteName: "missing"},
|
||||
{RemoteName: "forge", Found: true, Releases: []map[string]any{absolute, puppetRel("unkin-hello", "0.2.0"), puppetRel("unkin-hello", "0.10.0"), wrongFile, puppetRel("unkin-other", "1.0.0")}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := versionsOf(d.Releases); got != "0.10.0,0.3.0,0.2.0,0.1.0" {
|
||||
t.Fatalf("merged = %s, want newest semver first without foreign or misnamed releases", got)
|
||||
}
|
||||
for v, owner := range map[string]string{"0.1.0": "internal", "0.2.0": "internal", "0.3.0": "forge", "0.10.0": "forge"} {
|
||||
if d.Owners[v] != owner {
|
||||
t.Errorf("owner of %s = %q, want %q", v, d.Owners[v], owner)
|
||||
}
|
||||
}
|
||||
if d.Releases[2]["tag"] != "internal" {
|
||||
t.Errorf("duplicate 0.2.0 not taken from the first member: %v", d.Releases[2])
|
||||
}
|
||||
if d.Releases[1]["file_uri"] != "/v3/files/unkin-hello-0.3.0.tar.gz" {
|
||||
t.Errorf("file_uri = %v, want routed via the virtual", d.Releases[1]["file_uri"])
|
||||
}
|
||||
|
||||
mod, err := PuppetModule(d)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mod["slug"] != "unkin-hello" || mod["current_release"].(map[string]any)["version"] != "0.10.0" || len(mod["releases"].([]map[string]any)) != 4 {
|
||||
t.Fatalf("module = %v", mod)
|
||||
}
|
||||
|
||||
if _, err := MergePuppetReleases("unkin-hello", []PuppetMember{{RemoteName: "a"}, {RemoteName: "b"}}); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("no member has it: want ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// forgePages answers a member's /v3/releases like the Forge, two per page.
|
||||
func forgePages(slug string, versions []string, p string) ([]byte, error) {
|
||||
_, raw, _ := strings.Cut(p, "?")
|
||||
q, _ := url.ParseQuery(raw)
|
||||
offset, _ := strconv.Atoi(q.Get("offset"))
|
||||
end := min(offset+2, len(versions))
|
||||
var results []map[string]any
|
||||
for _, v := range versions[min(offset, end):end] {
|
||||
results = append(results, puppetRel(slug, v))
|
||||
}
|
||||
return json.Marshal(map[string]any{"pagination": map[string]int{"total": len(versions)}, "results": results})
|
||||
}
|
||||
|
||||
func puppetEngine(doc func(remote models.Remote, path string) ([]byte, error)) *Engine {
|
||||
return &Engine{
|
||||
mergeTTL: time.Minute,
|
||||
getRemote: func(_ context.Context, name string) (*models.Remote, error) {
|
||||
rt := models.RepoTypeRemote
|
||||
if name == "internal" {
|
||||
rt = models.RepoTypeLocal
|
||||
}
|
||||
return &models.Remote{Name: name, RepoType: rt}, nil
|
||||
},
|
||||
puppetDoc: func(_ context.Context, remote models.Remote, path string) ([]byte, error) { return doc(remote, path) },
|
||||
}
|
||||
}
|
||||
|
||||
func TestPuppetReleasesFollowsMemberPages(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
var asked []string
|
||||
e := puppetEngine(func(remote models.Remote, p string) ([]byte, error) {
|
||||
mu.Lock()
|
||||
asked = append(asked, remote.Name+" "+p)
|
||||
mu.Unlock()
|
||||
switch remote.Name {
|
||||
case "internal":
|
||||
return forgePages("puppetlabs-stdlib", []string{"9.6.0"}, p)
|
||||
case "gone":
|
||||
return nil, &proxy.ProxyError{Status: 404}
|
||||
}
|
||||
return forgePages("puppetlabs-stdlib", []string{"9.6.0", "9.5.0", "9.4.0", "8.0.0", "7.0.0"}, p)
|
||||
})
|
||||
virt := models.Virtual{Name: "puppet", PackageType: models.PackagePuppet, Members: []string{"internal", "gone", "forge"}}
|
||||
d, err := e.PuppetReleases(context.Background(), virt, "puppetlabs-stdlib")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if versionsOf(d.Releases) != "9.6.0,9.5.0,9.4.0,8.0.0,7.0.0" || d.Owners["9.6.0"] != "internal" || d.Owners["7.0.0"] != "forge" {
|
||||
t.Fatalf("merged = %s %v", versionsOf(d.Releases), d.Owners)
|
||||
}
|
||||
if len(asked) != 5 {
|
||||
t.Fatalf("want 1 internal + 1 gone + 3 forge pages, asked %v", asked)
|
||||
}
|
||||
for _, a := range asked {
|
||||
if strings.HasPrefix(a, "forge ") && !strings.Contains(a, "module=puppetlabs-stdlib") {
|
||||
t.Errorf("member page without module filter: %q", a)
|
||||
}
|
||||
}
|
||||
if _, err := e.PuppetReleases(context.Background(), virt, "puppetlabs-stdlib"); err != nil || len(asked) != 5 {
|
||||
t.Fatalf("merge not cached: %v, %d fetches", err, len(asked))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPuppetReleasesFailsClosed(t *testing.T) {
|
||||
for name, doc := range map[string]func(models.Remote, string) ([]byte, error){
|
||||
"member error": func(r models.Remote, p string) ([]byte, error) {
|
||||
if r.Name == "forge" {
|
||||
return nil, &proxy.ProxyError{Status: 503}
|
||||
}
|
||||
return forgePages("unkin-hello", []string{"0.1.0"}, p)
|
||||
},
|
||||
"short member list": func(models.Remote, string) ([]byte, error) {
|
||||
return []byte(`{"pagination":{"total":3},"results":[]}`), nil
|
||||
},
|
||||
} {
|
||||
e := puppetEngine(doc)
|
||||
virt := models.Virtual{Name: "puppet", PackageType: models.PackagePuppet, Members: []string{"internal", "forge"}}
|
||||
if _, err := e.PuppetReleases(context.Background(), virt, "unkin-hello"); err == nil || errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("%s: want a non-404 error, got %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPuppetRedirect(t *testing.T) {
|
||||
e := puppetEngine(func(r models.Remote, p string) ([]byte, error) {
|
||||
if r.Name == "internal" {
|
||||
return forgePages("unkin-hello", []string{"0.1.0"}, p)
|
||||
}
|
||||
return forgePages("unkin-hello", []string{"0.2.0", "0.1.0"}, p)
|
||||
})
|
||||
virt := models.Virtual{Name: "puppet", PackageType: models.PackagePuppet, Members: []string{"internal", "forge"}}
|
||||
for p, want := range map[string]string{
|
||||
"v3/files/unkin-hello-0.1.0.tar.gz": "https://a.example/api/v1/local/internal/v3/files/unkin-hello-0.1.0.tar.gz",
|
||||
"v3/releases/unkin-hello-0.2.0": "https://a.example/api/v1/remote/forge/v3/releases/unkin-hello-0.2.0",
|
||||
} {
|
||||
if loc, err := e.PuppetRedirect(context.Background(), virt, p, "https://a.example"); err != nil || loc != want {
|
||||
t.Errorf("%s -> %q %v, want %q", p, loc, err, want)
|
||||
}
|
||||
}
|
||||
for _, p := range []string{"v3/files/unkin-hello-0.3.0.tar.gz", "v3/files/unkin-hello-0.1.0.zip", "v3/releases/nope", "v3/users/unkin"} {
|
||||
if _, err := e.PuppetRedirect(context.Background(), virt, p, "https://a.example"); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("%s: want ErrNotFound, got %v", p, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPuppetReleasesCachesMissingModule(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
asked := map[string]int{}
|
||||
e := puppetEngine(func(r models.Remote, p string) ([]byte, error) {
|
||||
mu.Lock()
|
||||
asked[r.Name]++
|
||||
mu.Unlock()
|
||||
if r.Name == "internal" {
|
||||
return nil, &proxy.ProxyError{Status: 404}
|
||||
}
|
||||
return forgePages("unkin-nope", nil, p)
|
||||
})
|
||||
virt := models.Virtual{Name: "puppet", PackageType: models.PackagePuppet, Members: []string{"internal", "forge"}}
|
||||
for range 2 {
|
||||
if _, err := e.PuppetReleases(context.Background(), virt, "unkin-nope"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("want ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
if asked["internal"] != 1 || asked["forge"] != 1 {
|
||||
t.Fatalf("a module no member has must be cached, asked %v", asked)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package models
|
||||
|
||||
const PackageVCS PackageType = "vcs"
|
||||
|
||||
func init() { registerPackageType(PackageVCS) }
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { PackageTypeUI } from '../types';
|
||||
|
||||
const def: PackageTypeUI = {
|
||||
color: 'default',
|
||||
snippets: ({ proxy }) => {
|
||||
return [
|
||||
{
|
||||
title: 'Download a tag archive',
|
||||
language: 'bash',
|
||||
code: `curl -fsSLO ${proxy}/<owner>/<repo>/archive/refs/tags/<tag>.tar.gz`,
|
||||
},
|
||||
{
|
||||
title: 'Download a branch archive (redirects to its current commit)',
|
||||
language: 'bash',
|
||||
code: `curl -fsSL -o src.tar.gz ${proxy}/<owner>/<repo>/archive/refs/heads/<branch>.tar.gz`,
|
||||
},
|
||||
{
|
||||
title: 'Download a release asset',
|
||||
language: 'bash',
|
||||
code: `curl -fsSLO ${proxy}/<owner>/<repo>/releases/download/<tag>/<asset>`,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default def;
|
||||
Reference in New Issue
Block a user