Compare commits

..

12 Commits

Author SHA1 Message Date
unkin-agent ac6e1bf798 Key goproxy merge cache on owners and cache module misses
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
2026-10-18 21:16:04 +11:00
unkin-agent c6f7eb3eb7 Add goproxy virtual repositories
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
2026-10-18 21:02:50 +11:00
unkin-agent 8e96aabbef merge master
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
2026-10-18 20:48:31 +11:00
unkin-agent 0cfde41612 Add ansible virtual repositories (#158)
ci/woodpecker/tag/docker Pipeline was successful
Clients need one Galaxy server to install both internal collections and galaxy.ansible.com ones; ansible has no virtual type.

- merge member collection version lists in member order, the earlier member winning a duplicate version, cached with the shared virtual merge cache
- paginate limit/offset over the merged list, following remote member pages
- serve version detail from the owning member with href and download_url via the virtual; downloads 302 to the member through the member-redirect validation
- fail closed with 502 on a member error or a member versions list shorter than its count
- add docs/ansible/virtual.md and an e2e-docker case driving real ansible-galaxy

requires unkin/artifactapi#157

Reviewed-on: #158
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-10-18 18:09:08 +11:00
unkin-agent a00e45c3a7 Add local ansible Galaxy repositories (#157)
Internal Ansible collections can only be installed from galaxy.ansible.com through the remote; there is nowhere to publish them for ansible-galaxy.

- add the Galaxy v3 publish endpoint (multipart, base64 parts) with an import task that is already completed
- read collection_info from MANIFEST.json under size and decompression caps, rejecting escaping paths and links
- serve collection, paginated versions (root-relative links, as Galaxy does), version detail and artifact downloads
- reject an existing namespace.name:version with 409
- add docs/ansible/local.md, a UI snippet and an e2e-docker case driving real ansible-galaxy

requires unkin/artifactapi#137

Reviewed-on: #157
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-10-18 18:01:09 +11:00
unkin-agent ca08c580b8 Add ansible Galaxy remote type (#137)
ansible-galaxy installs pull collections straight from galaxy.ansible.com because artifactapi has no Galaxy remote type.

- add `ansible` package type proxying the Galaxy v3 API (discovery, collection versions, artifact downloads)
- forward digits-only `limit` (capped at 100) and `offset` on metadata paths, canonicalised; tarball paths forward no query
- rewrite only `href`, `download_url` and `links.*` to root-relative remote paths so tarballs flow through artifactapi
- treat `artifacts/*.tar.gz` as immutable and JSON metadata as mutable
- add `docs/ansible/remote.md`, a UI type def and an ansible e2e caching case

requires unkin/artifactapi#146

Reviewed-on: #137
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-10-18 12:08:55 +11:00
unkin-agent ab723147c9 Forward allowlisted query params for remote providers (#146)
Remote proxying drops the query string, so paginated JSON APIs (e.g. Galaxy `versions/?limit=&offset=`) cannot be served. Forwarding the raw query would let arbitrary params mint unbounded cache entries and bypass path-anchored blocklists.

- add `provider.QueryForwarder`, mapping each forwarded param for a path to a validator that canonicalises or rejects its value
- forward only validated params upstream and into the cache key, single-valued and in sorted order
- classify on the bare path so blocklist/allowlist/mutability patterns ignore the query
- evict a path together with its `path?...` query variants
- leave providers without `QueryForwarder` unchanged

Reviewed-on: #146
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-10-18 12:08:07 +11:00
unkin-agent d88a047788 Fail GitHub scans on asset errors and drop sync state with its remote (#135)
ci/woodpecker/tag/docker Pipeline was successful
A GitHub release scan that hit a transient asset error still saved the release-list ETag, so the failed asset was never retried. Assets that are corrupt, deleted or truncated upstream fail on every retry, so they must not keep the remote failed.

- fail the rpm/deb/alpine scan on transient asset fetch, short read or metadata insert errors, so the ETag is not saved and the backoff retry re-derives the asset
- add `provider.ErrInvalidPackage` for unparseable packages and 404/410/416 asset downloads; such assets are logged and skipped
- decide a ranged fetch holds the whole object from Content-Length or the Content-Range total, or a clean EOF when the total is unknown
- add a NOT VALID cascading FK from the GitHub sync-state tables to `remotes`

Reviewed-on: #135
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-10-18 02:33:00 +11:00
unkin-agent b0d60b3e8a Release Redis locks only while the caller still owns them (#156)
AcquireLock stored a constant value and ReleaseLock was an unconditional DEL, so a holder that outlived its TTL deleted the next holder's lock. This hits Fetch, Evict and gems push/yank.

- store a random owner token with SET NX PX and return it from AcquireLock
- release via a Lua compare-and-delete keyed on that token
- carry the token through Fetch, Evict and Engine.Lock's release func
- release the Fetch lock with an uncancelled context
- make Engine.Lock's busy 503 message generic

Reviewed-on: #156
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-10-18 02:32:40 +11:00
unkin-agent 5a2bfdf73a merge master
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
2026-10-17 13:20:00 +11:00
unkin-agent 8378701d9a merge master
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
2026-10-17 12:55:45 +11:00
unkin-agent a51323c725 Add goproxy local repositories for private Go modules
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
2026-10-16 23:32:48 +11:00
64 changed files with 4122 additions and 100 deletions
+2 -1
View File
@@ -27,12 +27,13 @@ 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)
- `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)
- `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)
- `goproxy`: [remote](docs/goproxy/remote.md)
- `goproxy`: [remote](docs/goproxy/remote.md), [local](docs/goproxy/local.md), [virtual](docs/goproxy/virtual.md)
- `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)
+26
View File
@@ -0,0 +1,26 @@
# Local ansible repository
A local `ansible` repo is a Galaxy v3 server: `ansible-galaxy collection publish`
and `ansible-galaxy collection install` work against `/api/v1/local/<repo>`.
`publish` needs an API key; any value works.
```bash
ansible-galaxy collection build
ansible-galaxy collection publish unkin-hello-1.0.0.tar.gz \
--server https://artifactapi.example.com/api/v1/local/<repo>/ --api-key unused
ansible-galaxy collection install unkin.hello \
--server https://artifactapi.example.com/api/v1/local/<repo>/
```
Publishing reads `collection_info` from the tarball's `MANIFEST.json` and
finishes the import synchronously, so the import task is already `completed`.
Uploads over 256 MiB, tarballs with members or symlinks escaping the collection root, or
with an invalid name, version or dependency map are rejected with 400.
The repo serves `api/v3/collections/<ns>/<name>/`, the paginated
`versions/` list (`limit` up to 100, `offset`), `versions/<version>/` with
`download_url`, `artifact.sha256` and `metadata.dependencies`, and
`artifacts/<ns>-<name>-<version>.tar.gz`.
Versions are immutable: publishing an existing `namespace.name:version` is
rejected with 409.
+11
View File
@@ -0,0 +1,11 @@
# `ansible` remote
Shared classification, TTL and eviction rules: [caching](../common/caching.md).
| Type | Mutable (auto-detected) | Immutable (auto-detected) |
|---|---|---|
| `ansible` | Galaxy `api/` JSON (links rewritten, `limit`/`offset` forwarded) | `artifacts/*.tar.gz` |
```bash
ansible-galaxy collection install community.general -s https://artifactapi.example/api/v1/remote/galaxy/api/
```
+28
View File
@@ -0,0 +1,28 @@
# `ansible` virtual
An ansible virtual serves one Galaxy v3 API over local and remote ansible
members: `ansible-galaxy collection install` works against
`/api/v1/virtual/<name>/`.
- `versions/` concatenates member version lists in member order; on a
duplicate version the earlier member wins. Pagination (`limit` up to 100,
`offset`) runs over the merged list.
- `versions/<version>/` is the owning member's detail with `href` and
`download_url` pointing at the virtual; the download 302s to that member.
- A member error fails the request with 502; a member without the collection
is skipped. Merges are cached for 60s.
```hcl
resource "artifactapi_virtual" "ansible" {
name = "ansible"
package_type = "ansible"
members = [artifactapi_local_ansible.internal.name, artifactapi_remote_ansible.galaxy.name]
}
```
```bash
ansible-galaxy collection install unkin.hello community.general \
--server https://artifactapi.example.com/api/v1/virtual/ansible/
```
Publish to the local member directly; the virtual is read-only.
+1 -1
View File
@@ -93,7 +93,7 @@ Force a moving path mutable, e.g. branch tarballs on `generic`:
## Built-in classification per type
Each type's page lists its auto-detected mutable and immutable paths:
[alpine](../alpine/remote.md), [alpine github](../alpine/remote-github.md),
[alpine](../alpine/remote.md), [alpine github](../alpine/remote-github.md), [ansible](../ansible/remote.md),
[cargo](../cargo/remote.md), [deb](../deb/remote.md), [deb github](../deb/remote-github.md),
[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),
+20
View File
@@ -0,0 +1,20 @@
# Local Go module proxy
A local `goproxy` repo serves private modules over the GOPROXY protocol
(`@v/list`, `@latest`, `@v/<ver>.info|.mod|.zip`). Publish a version by
uploading its files under the case-encoded module path (uppercase as `!lower`):
```sh
for ext in zip mod info; do
curl -fsSL --upload-file v0.1.0.$ext \
https://artifactapi.k8s.syd1.au.unkin.net/api/v2/remotes/go-internal/files/example.unkin.net/hello/@v/v0.1.0.$ext
done
GOPROXY=https://artifactapi.k8s.syd1.au.unkin.net/api/v1/local/go-internal,https://proxy.golang.org,direct \
GONOSUMDB=example.unkin.net go get example.unkin.net/hello@latest
```
Build the zip with `golang.org/x/mod/zip` or reuse the files `go mod download`
leaves in `$(go env GOMODCACHE)/cache/download`. Uploads are validated: zips must
pass `zip.CheckZip` (every file under `module@version/`), `.mod` must declare the
module, and `.info` must match the version. `.info` is optional; it is
synthesized from the version when absent. `@v/list` lists versions with a `.mod`.
+30
View File
@@ -0,0 +1,30 @@
# `goproxy` virtual
A goproxy virtual serves one GOPROXY over local and remote goproxy members:
`GOPROXY=https://artifactapi.example.com/api/v1/virtual/<name>`.
- `<module>/@v/list` is the union of member versions, sorted by semver.
- `<module>/@latest` is the highest release in that union, else the highest
prerelease; with no listed versions it falls back to the first member that
answers `@latest`.
- `@latest` and `@v/<version>.{info,mod,zip}` 302 to the first member, in
member order, that lists the version (or, for unlisted pseudo-versions,
serves its `.info`). Redirects are root-relative and `no-store`.
- Module paths are case-encoded (uppercase as `!lower`).
- A member error fails the request with 502; a member answering 404 or 410 is
skipped. Merged lists are cached for 60s.
```hcl
resource "artifactapi_virtual" "go" {
name = "go"
package_type = "goproxy"
members = [artifactapi_local_goproxy.internal.name, artifactapi_remote_goproxy.goproxy.name]
}
```
```sh
export GOPROXY=https://artifactapi.example.com/api/v1/virtual/go GONOSUMDB=example.unkin.net
go get example.unkin.net/hello@latest golang.org/x/text@latest
```
Publish to the local member directly; the virtual is read-only.
+6 -2
View File
@@ -31,12 +31,16 @@ already-running stack.
`node:22` client: `npm publish` unscoped + scoped, `npm install` + `require()`,
second version, republish rejected), gems (stock `ruby:3.3`: `gem push`,
`gem install`, `bundle install` + `require`, republish rejected, `gem yank`,
yanked re-push rejected).
yanked re-push rejected), ansible (stock `ansible-galaxy`: `collection build`,
`publish`, republish rejected, `install` + module run).
- **Virtual repositories** — pypi simple-index merge and helm `index.yaml` merge
across two members; npm packument merge over two locals via a stock `node:22`
client (merged versions, install through the member redirect); gems compact
index merge over two locals via stock `ruby:3.3` bundler (member order wins,
downloads 302 to the owning member).
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).
- **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,
+66
View File
@@ -0,0 +1,66 @@
//go:build dockere2e
package e2edocker
import (
"fmt"
"os"
"os/exec"
"strings"
"testing"
"time"
)
// ansibleHelloScript builds unkin.e2e_hello 1.0.0 with one module into /w/dist.
const ansibleHelloScript = `pip install -q ansible-core >/dev/null 2>&1
mkdir -p /w && cd /w && ansible-galaxy collection init unkin.e2e_hello >/dev/null
cd unkin/e2e_hello && sed -i 's/^version: .*/version: 1.0.0/' galaxy.yml && mkdir -p plugins/modules
printf '%s\n' 'DOCUMENTATION = r"""' 'module: hello' 'short_description: e2e greeting' 'description: Returns a greeting.' 'options:' ' name: {type: str, default: world, description: Who to greet.}' '"""' \
'from ansible.module_utils.basic import AnsibleModule' \
'm = AnsibleModule(argument_spec=dict(name=dict(type="str", default="world")))' \
'm.exit_json(changed=False, msg="hello " + m.params["name"])' > plugins/modules/hello.py
ansible-galaxy collection build --output-path /w/dist >/dev/null
`
// ansibleHelloPlay runs unkin.e2e_hello.hello from collections in /w/coll.
const ansibleHelloPlay = `export ANSIBLE_COLLECTIONS_PATH=/w/coll
ansible-doc unkin.e2e_hello.hello | grep -q 'Returns a greeting'
printf -- '- hosts: localhost\n gather_facts: false\n tasks:\n - unkin.e2e_hello.hello: {name: e2e}\n register: r\n - debug: {msg: "PLAY {{ r.msg }}"}\n' > /w/play.yml
ansible-playbook -i localhost, -c local /w/play.yml | grep -o 'PLAY hello e2e' | head -1
`
// TestLocalAnsiblePublishInstall builds a collection with a stock
// ansible-galaxy client, publishes it to a local ansible repo, rejects the
// republish, installs it back and runs its module.
func TestLocalAnsiblePublishInstall(t *testing.T) {
network := os.Getenv("COMPOSE_NETWORK")
internal := 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")
}
name := fmt.Sprintf("e2e-ansible-local-%d", time.Now().UnixNano())
createRepo(t, fmt.Sprintf(`{"name":%q,"package_type":"ansible","repo_type":"local"}`, name))
defer deleteRepo(t, name)
script := "set -euo pipefail\n" + ansibleHelloScript + `
ansible-galaxy collection publish /w/dist/unkin-e2e_hello-1.0.0.tar.gz --server "$SRV" --api-key unused
if ansible-galaxy collection publish /w/dist/unkin-e2e_hello-1.0.0.tar.gz --server "$SRV" --api-key unused; then echo "republish accepted"; exit 1; fi
ansible-galaxy collection install unkin.e2e_hello:==1.0.0 --server "$SRV" -p /w/coll
` + ansibleHelloPlay
srv := strings.TrimRight(internal, "/") + "/api/v1/local/" + name + "/"
out, err := exec.Command("docker", "run", "--rm", "--network", network,
"-e", "SRV="+srv, "python:3.12-slim", "bash", "-c", script).CombinedOutput()
if err != nil {
t.Fatalf("ansible-galaxy client run failed: %v\n%s", err, out)
}
for _, want := range []string{"successfully published and imported", "HTTP Code: 409", "unkin.e2e_hello:1.0.0 was installed successfully", "PLAY hello e2e"} {
if !strings.Contains(string(out), want) {
t.Fatalf("missing %q in client output:\n%s", want, out)
}
}
}
+64
View File
@@ -0,0 +1,64 @@
//go:build dockere2e
package e2edocker
import (
"fmt"
"os"
"os/exec"
"strings"
"testing"
"time"
)
// TestVirtualAnsibleInstall publishes a collection to the second of two
// ansible locals and installs it with stock ansible-galaxy through a virtual
// over both, the download 302ing to the owning member.
func TestVirtualAnsibleInstall(t *testing.T) {
network := os.Getenv("COMPOSE_NETWORK")
internal := 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-ansible-a-%d", id), fmt.Sprintf("e2e-ansible-b-%d", id), fmt.Sprintf("e2e-ansible-v-%d", id)
for _, name := range []string{a, b} {
createRepo(t, fmt.Sprintf(`{"name":%q,"package_type":"ansible","repo_type":"local"}`, name))
defer deleteRepo(t, name)
}
createVirtual(t, fmt.Sprintf(`{"name":%q,"package_type":"ansible","members":[%q,%q]}`, virt, a, b))
defer deleteVirtual(t, virt)
base := strings.TrimRight(internal, "/")
script := "set -euo pipefail\n" + ansibleHelloScript + `
ansible-galaxy collection publish /w/dist/unkin-e2e_hello-1.0.0.tar.gz --server "$BASE/api/v1/local/$B/" --api-key unused
V="$BASE/api/v1/virtual/$VIRT"
ansible-galaxy collection install unkin.e2e_hello --server "$V/" -p /w/coll
DL=$(python3 -c 'import json,sys,urllib.request as u;print(json.load(u.urlopen(sys.argv[1]))["download_url"])' "$V/api/v3/collections/unkin/e2e_hello/versions/1.0.0/")
echo "REDIRECT $(python3 -c 'import sys,urllib.request as u
class N(u.HTTPRedirectHandler):
def redirect_request(self,*a): return None
try: u.build_opener(N).open(sys.argv[1])
except u.HTTPError as e: print(e.code, e.headers["Location"])' "$DL")"
` + ansibleHelloPlay
out, err := exec.Command("docker", "run", "--rm", "--network", network,
"-e", "BASE="+base, "-e", "B="+b, "-e", "VIRT="+virt,
"python:3.12-slim", "bash", "-c", script).CombinedOutput()
if err != nil {
t.Fatalf("ansible-galaxy client run failed: %v\n%s", err, out)
}
for _, want := range []string{
"unkin.e2e_hello:1.0.0 was installed successfully",
"REDIRECT 302 " + base + "/api/v1/local/" + b + "/artifacts/unkin-e2e_hello-1.0.0.tar.gz",
"PLAY hello e2e",
} {
if !strings.Contains(string(out), want) {
t.Fatalf("missing %q in client output:\n%s", want, out)
}
}
}
+7
View File
@@ -0,0 +1,7 @@
//go:build dockere2e
package e2edocker
func init() {
cachingCases = append(cachingCases, cachingCase{"ansible", "galaxy/artifacts/ns-col-1.0.0.tar.gz", "galaxy/artifacts/ns-col-1.0.0.tar.gz"})
}
+57
View File
@@ -0,0 +1,57 @@
//go:build dockere2e
package e2edocker
import (
"bytes"
"net/http"
"os"
"path/filepath"
"testing"
"golang.org/x/mod/module"
modzip "golang.org/x/mod/zip"
)
// TestLocalGoProxy publishes two module versions and reads them back over the
// GOPROXY protocol.
func TestLocalGoProxy(t *testing.T) {
createRepo(t, `{"name":"local-go","package_type":"goproxy","repo_type":"local"}`)
defer deleteRepo(t, "local-go")
const mod = "example.unkin.net/!hello"
gomod := []byte("module example.unkin.net/Hello\n\ngo 1.25\n")
var zip010 []byte
for _, ver := range []string{"v0.1.0", "v0.2.0"} {
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "go.mod"), gomod, 0o600)
os.WriteFile(filepath.Join(dir, "hello.go"), []byte("package hello\n"), 0o600)
var buf bytes.Buffer
if err := modzip.CreateFromDir(&buf, module.Version{Path: "example.unkin.net/Hello", Version: ver}, dir); err != nil {
t.Fatal(err)
}
if ver == "v0.1.0" {
zip010 = buf.Bytes()
}
uploadFile(t, "local-go", mod+"/@v/"+ver+".zip", buf.Bytes(), "application/zip")
uploadFile(t, "local-go", mod+"/@v/"+ver+".mod", gomod, "text/plain")
}
resp, body := doRequest(t, http.MethodPut, api("/api/v2/remotes/local-go/files/"+mod+"/@v/v0.3.0.zip"), []byte("junk"), "application/zip")
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("invalid zip: status %d: %s", resp.StatusCode, body)
}
for path, want := range map[string]string{
"/@v/list": "v0.1.0\nv0.2.0\n",
"/@latest": `{"Version":"v0.2.0"}`,
"/@v/v0.1.0.info": `{"Version":"v0.1.0"}`,
"/@v/v0.1.0.mod": string(gomod),
"/@v/v0.1.0.zip": string(zip010),
} {
resp, body := doRequest(t, http.MethodGet, api("/api/v1/local/local-go/"+mod+path), nil, "")
if resp.StatusCode != http.StatusOK || string(body) != want {
t.Errorf("%s: status %d body %q", path, resp.StatusCode, body)
}
}
}
+81
View File
@@ -0,0 +1,81 @@
//go:build dockere2e
package e2edocker
import (
"bytes"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"golang.org/x/mod/module"
modzip "golang.org/x/mod/zip"
)
// TestVirtualGoProxy publishes to two local goproxy repos and drives a stock go
// client through a virtual over both: merged versions, member order winning a
// duplicate version, and version files redirected to the owning member.
func TestVirtualGoProxy(t *testing.T) {
network := os.Getenv("COMPOSE_NETWORK")
internal := 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-go-a-%d", id), fmt.Sprintf("e2e-go-b-%d", id), fmt.Sprintf("e2e-go-v-%d", id)
for _, name := range []string{a, b} {
createRepo(t, fmt.Sprintf(`{"name":%q,"package_type":"goproxy","repo_type":"local"}`, name))
defer deleteRepo(t, name)
}
createVirtual(t, fmt.Sprintf(`{"name":%q,"package_type":"goproxy","members":[%q,%q]}`, virt, a, b))
defer deleteVirtual(t, virt)
const mod = "example.unkin.net/hello"
gomod := []byte("module " + mod + "\n\ngo 1.25\n")
for _, p := range [][2]string{{a, "v0.1.0"}, {b, "v0.1.0"}, {b, "v0.2.0"}} {
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "go.mod"), gomod, 0o600)
os.WriteFile(filepath.Join(dir, "hello.go"), []byte("package hello\n\nconst Greeting = \""+p[0]+":"+p[1]+"\"\n"), 0o600)
var buf bytes.Buffer
if err := modzip.CreateFromDir(&buf, module.Version{Path: mod, Version: p[1]}, dir); err != nil {
t.Fatal(err)
}
uploadFile(t, p[0], mod+"/@v/"+p[1]+".zip", buf.Bytes(), "application/zip")
uploadFile(t, p[0], mod+"/@v/"+p[1]+".mod", gomod, "text/plain")
}
base := strings.TrimRight(internal, "/")
script := `set -euo pipefail
export GOPROXY="$BASE/api/v1/virtual/$V" GONOSUMDB=example.unkin.net GOFLAGS=-mod=mod GOTOOLCHAIN=local
mkdir -p /w && cd /w
printf 'module consumer\n\ngo 1.25\n' > go.mod
printf 'package main\n\nimport "example.unkin.net/hello"\n\nfunc main() { println("RUN " + hello.Greeting) }\n' > main.go
echo "VERSIONS $(go list -m -versions example.unkin.net/hello)"
go get example.unkin.net/hello@latest && go run . 2>&1
go get example.unkin.net/hello@v0.1.0 && go run . 2>&1
echo "REDIRECT $(curl -s -o /dev/null -w '%{redirect_url}' "$GOPROXY/example.unkin.net/hello/@v/v0.1.0.zip")"`
out, err := exec.Command("docker", "run", "--rm", "--network", network,
"-e", "BASE="+base, "-e", "V="+virt, "golang:1.25", "bash", "-c", script).CombinedOutput()
if err != nil {
t.Fatalf("go run failed: %v\n%s", err, out)
}
for _, want := range []string{
"VERSIONS example.unkin.net/hello v0.1.0 v0.2.0",
"RUN " + b + ":v0.2.0",
"RUN " + a + ":v0.1.0",
"REDIRECT " + base + "/api/v1/local/" + a + "/example.unkin.net/hello/@v/v0.1.0.zip",
} {
if !strings.Contains(string(out), want) {
t.Fatalf("missing %q in go output:\n%s", want, out)
}
}
}
+1
View File
@@ -20,6 +20,7 @@ require (
github.com/testcontainers/testcontainers-go/modules/redis v0.42.0
github.com/ulikunitz/xz v0.5.16
golang.org/x/crypto v0.54.0
golang.org/x/mod v0.37.0
golang.org/x/sync v0.22.0
golang.org/x/time v0.15.0
gopkg.in/yaml.v3 v3.0.1
+4
View File
@@ -227,6 +227,8 @@ golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
@@ -247,6 +249,8 @@ golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+289
View File
@@ -0,0 +1,289 @@
package v1
import (
"bytes"
"cmp"
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"slices"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"git.unkin.net/unkin/artifactapi/internal/database"
"git.unkin.net/unkin/artifactapi/internal/provider/ansible"
"git.unkin.net/unkin/artifactapi/internal/virtual"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
var ansibleMaxPublishBytes int64 = 256 << 20
// ansibleError writes a Galaxy v3 error document, which ansible-galaxy prints.
func ansibleError(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]any{"errors": []map[string]string{{
"status": strconv.Itoa(status), "code": strings.ToLower(strings.ReplaceAll(http.StatusText(status), " ", "_")),
"title": http.StatusText(status), "detail": msg,
}}})
}
func writeAnsibleJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Vary", "Host, X-Forwarded-Proto")
w.WriteHeader(status)
enc := json.NewEncoder(w)
enc.SetEscapeHTML(false)
_ = enc.Encode(v)
}
func isAnsiblePublish(path string) bool {
return strings.TrimSuffix(path, "/")+"/" == ansible.PublishPath
}
func (h *ProxyHandler) handleAnsiblePublish(w http.ResponseWriter, r *http.Request) {
remote, err := h.db.GetRemote(r.Context(), chi.URLParam(r, "localName"))
if err != nil || remote.RepoType != models.RepoTypeLocal || remote.PackageType != models.PackageAnsible {
ansibleError(w, http.StatusMethodNotAllowed, "publish is only supported on local ansible repositories")
return
}
base, ok := requestBaseURL(w, r)
if !ok {
return
}
r.Body = http.MaxBytesReader(w, r.Body, ansibleMaxPublishBytes)
file, sum, err := readAnsibleUpload(r)
if err != nil {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
ansibleError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("upload exceeds %d bytes", ansibleMaxPublishBytes))
return
}
ansibleError(w, http.StatusBadRequest, err.Error())
return
}
digest := sha256.Sum256(file)
if got := hex.EncodeToString(digest[:]); sum != "" && !strings.EqualFold(sum, got) {
ansibleError(w, http.StatusBadRequest, "sha256 does not match the uploaded file")
return
}
e, err := ansible.ParseCollection(bytes.NewReader(file))
if err != nil {
ansibleError(w, http.StatusBadRequest, err.Error())
return
}
e.SHA256, e.Size, e.CreatedAt = hex.EncodeToString(digest[:]), int64(len(file)), time.Now().UTC()
id := e.Namespace + "." + e.Name + ":" + e.Version
unlock, err := h.engine.Lock(r.Context(), remote.Name, "_ansiblelock/"+e.TaskID(), gemsLockWait, 5*time.Minute)
if err != nil {
ansibleError(w, http.StatusServiceUnavailable, id+" is being published, retry")
return
}
defer unlock()
conflict := func() {
ansibleError(w, http.StatusConflict, id+" already exists; overwrites are not allowed")
}
if f, err := h.db.GetLocalFile(r.Context(), remote.Name, e.IndexPath()); err != nil {
ansibleError(w, http.StatusInternalServerError, err.Error())
return
} else if f != nil {
conflict()
return
}
// The version is not live, so a tarball left by a failed publish is safe to drop.
artifact := ansible.ArtifactPrefix + e.Filename()
fail := func(status int, msg string) {
if err := h.db.DeleteLocalFile(context.WithoutCancel(r.Context()), remote.Name, artifact); err != nil {
slog.Error("ansible publish cleanup failed", "repo", remote.Name, "path", artifact, "error", err)
}
ansibleError(w, status, msg)
}
if ok, err := h.npmStoreImmutable(r.Context(), remote.Name, artifact, file, "application/gzip"); err != nil {
fail(http.StatusInternalServerError, err.Error())
return
} else if !ok {
fail(http.StatusConflict, id+" already exists; overwrites are not allowed")
return
}
entry, err := json.Marshal(e)
if err != nil {
fail(http.StatusInternalServerError, err.Error())
return
}
// The index entry gates visibility, so it is written last.
if _, err := h.npmStore(r.Context(), remote.Name, e.IndexPath(), entry, "application/json", false); err != nil {
if errors.Is(err, database.ErrAlreadyExists) {
conflict()
return
}
fail(http.StatusInternalServerError, err.Error())
return
}
slog.Info("local ansible collection published", "repo", remote.Name, "collection", id)
repoBase := base + "/api/v1/local/" + url.PathEscape(remote.Name)
writeAnsibleJSON(w, http.StatusAccepted, map[string]string{"task": repoBase + "/api/v3/imports/collections/" + e.TaskID() + "/"})
}
// readAnsibleUpload returns the multipart "file" part, which ansible-galaxy
// base64-encodes, and the optional "sha256" field sent with it.
func readAnsibleUpload(r *http.Request) ([]byte, string, error) {
mr, err := r.MultipartReader()
if err != nil {
return nil, "", errors.New("expected a multipart/form-data upload")
}
var file []byte
var sum string
for {
part, err := mr.NextPart()
if err == io.EOF {
break
}
if err != nil {
return nil, "", err
}
switch part.FormName() {
case "file":
if file != nil {
return nil, "", errors.New("multiple file parts")
}
var src io.Reader = part
switch enc := strings.ToLower(part.Header.Get("Content-Transfer-Encoding")); enc {
case "base64":
src = base64.NewDecoder(base64.StdEncoding, part)
case "", "binary", "7bit", "8bit":
default:
return nil, "", fmt.Errorf("unsupported Content-Transfer-Encoding %q", enc)
}
if file, err = io.ReadAll(src); err != nil {
return nil, "", err
}
case "sha256":
b, err := io.ReadAll(io.LimitReader(part, 128))
if err != nil {
return nil, "", err
}
sum = strings.TrimSpace(string(b))
}
}
if file == nil {
return nil, "", errors.New("missing file part")
}
return file, sum, nil
}
// serveLocalAnsible answers the Galaxy v3 API of a local ansible repo.
func (h *ProxyHandler) serveLocalAnsible(w http.ResponseWriter, r *http.Request, remote *models.Remote, ap ansible.APIPath) {
base, ok := requestBaseURL(w, r)
if !ok {
return
}
body, err := h.localAnsibleDoc(r.Context(), remote.Name, ap, r.URL.Query(), base+"/api/v1/local/"+url.PathEscape(remote.Name))
if errors.Is(err, virtual.ErrNotFound) {
ansibleError(w, http.StatusNotFound, "not found")
return
}
if err != nil {
ansibleError(w, http.StatusInternalServerError, err.Error())
return
}
w.Header().Set("X-Artifact-Source", "local")
writeAnsibleJSON(w, http.StatusOK, body)
}
// LocalAnsible serves a local ansible repo's collection, full versions list
// or version detail to ansible virtuals, with root-relative URLs.
func (h *ProxyHandler) LocalAnsible(ctx context.Context, repo, path string) ([]byte, error) {
doc, err := h.localAnsibleDoc(ctx, repo, ansible.ParseAPIPath(path), nil, "/api/v1/local/"+url.PathEscape(repo))
if err != nil {
return nil, err
}
return json.Marshal(doc)
}
// localAnsibleDoc builds the document for ap; a nil q lists every version.
func (h *ProxyHandler) localAnsibleDoc(ctx context.Context, repo string, ap ansible.APIPath, q url.Values, base string) (any, error) {
switch ap.Kind {
case ansible.APIRoot:
return ansible.Root(), nil
case ansible.APIImport:
ns, rest, _ := strings.Cut(ap.TaskID, "-")
name, version, _ := strings.Cut(rest, "-")
entries, err := h.localAnsibleEntries(ctx, repo, ns, name)
if err != nil {
return nil, err
}
for _, e := range entries {
if e.Version == version {
return ansible.ImportTask(e), nil
}
}
case ansible.APICollection, ansible.APIVersions, ansible.APIVersion:
entries, err := h.localAnsibleEntries(ctx, repo, ap.Namespace, ap.Name)
if err != nil || len(entries) == 0 {
return nil, cmp.Or(err, virtual.ErrNotFound)
}
refs := make([]ansible.VersionRef, len(entries))
for i, e := range entries {
refs[i] = e.Ref()
}
switch ap.Kind {
case ansible.APICollection:
return ansible.CollectionDoc(base, ap.Namespace, ap.Name, refs), nil
case ansible.APIVersions:
limit, offset := len(refs), 0
if q != nil {
limit, offset = ansible.Page(q)
}
return ansible.VersionsPage(base, ap.Namespace, ap.Name, refs, limit, offset), nil
}
for _, e := range entries {
if e.Version == ap.Version {
return ansible.VersionDoc(base, e), nil
}
}
}
return nil, virtual.ErrNotFound
}
// localAnsibleEntries returns a collection's versions, newest publish first.
func (h *ProxyHandler) localAnsibleEntries(ctx context.Context, repo, ns, name string) ([]ansible.Entry, error) {
if !ansible.NameRe.MatchString(ns) || !ansible.NameRe.MatchString(name) {
return nil, nil
}
prefix := ansible.IndexPrefix + ns + "/" + name + "/"
files, err := h.db.ListLocalFilesByPrefix(ctx, repo, prefix)
if err != nil {
return nil, err
}
slices.SortFunc(files, func(a, b database.LocalFile) int { return cmp.Compare(b.ID, a.ID) })
var out []ansible.Entry
for _, f := range files {
// LIKE treats '_' as a wildcard, so the prefix match can over-select.
if !strings.HasPrefix(f.FilePath, prefix) {
continue
}
raw, err := h.readLocalBlob(ctx, f.ContentHash)
if err != nil {
return nil, err
}
var e ansible.Entry
if err := json.Unmarshal(raw, &e); err != nil {
return nil, fmt.Errorf("decode %s: %w", f.FilePath, err)
}
out = append(out, e)
}
return out, nil
}
+275
View File
@@ -0,0 +1,275 @@
package v1
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"encoding/base64"
"encoding/json"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/textproto"
"strings"
"testing"
"git.unkin.net/unkin/artifactapi/internal/virtual"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
func testCollection(t *testing.T, ns, name, version string) []byte {
t.Helper()
manifest := `{"collection_info":{"namespace":"` + ns + `","name":"` + name + `","version":"` + version + `","dependencies":{"ansible.posix":">=1.0.0"}}}`
var buf bytes.Buffer
zw := gzip.NewWriter(&buf)
tw := tar.NewWriter(zw)
_ = tw.WriteHeader(&tar.Header{Name: "MANIFEST.json", Mode: 0o644, Size: int64(len(manifest))})
_, _ = tw.Write([]byte(manifest))
_ = tw.Close()
_ = zw.Close()
return buf.Bytes()
}
// ansiblePublish uploads like ansible-galaxy: the file part base64-encoded
// whenever a sha256 field is sent, raw otherwise.
func ansiblePublish(t *testing.T, url string, tarball []byte, sum string) (int, string) {
t.Helper()
var body bytes.Buffer
mw := multipart.NewWriter(&body)
hdr := textproto.MIMEHeader{"Content-Disposition": {`form-data; name="file"; filename="c.tar.gz"`}}
data := tarball
if sum != "" {
_ = mw.WriteField("sha256", sum)
hdr.Set("Content-Transfer-Encoding", "base64")
data = []byte(base64.StdEncoding.EncodeToString(tarball))
}
fw, _ := mw.CreatePart(hdr)
_, _ = fw.Write(data)
_ = mw.Close()
req, _ := http.NewRequest(http.MethodPost, url, &body)
req.Header.Set("Content-Type", mw.FormDataContentType())
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
var b bytes.Buffer
_, _ = b.ReadFrom(resp.Body)
return resp.StatusCode, b.String()
}
func getJSON(t *testing.T, url string, v any) {
t.Helper()
code, body := npmDo(t, http.MethodGet, url, "")
if code != http.StatusOK {
t.Fatalf("GET %s = %d: %s", url, code, body)
}
if err := json.Unmarshal([]byte(body), v); err != nil {
t.Fatalf("GET %s: %v: %s", url, err, body)
}
}
func TestAnsiblePublishAndServe(t *testing.T) {
h, srv := npmTestServer(t)
repo := npmRepo(t, h, models.PackageAnsible)
base := srv.URL + "/api/v1/local/" + repo + "/"
publish := base + "api/v3/artifacts/collections/"
v1 := testCollection(t, "unkin", "e2e_hello", "1.0.0")
code, body := ansiblePublish(t, publish, v1, sha256Hex(v1))
if code != http.StatusAccepted {
t.Fatalf("publish = %d: %s", code, body)
}
var task struct{ Task string }
_ = json.Unmarshal([]byte(body), &task)
if task.Task != base+"api/v3/imports/collections/unkin-e2e_hello-1.0.0/" {
t.Fatalf("task = %q", task.Task)
}
var st map[string]any
getJSON(t, task.Task, &st)
if st["state"] != "completed" || st["finished_at"] == "" {
t.Fatalf("task = %v", st)
}
if code, _ := npmDo(t, http.MethodGet, base+"api/v3/imports/collections/unkin-e2e_hello-9.9.9/", ""); code != http.StatusNotFound {
t.Errorf("unknown task = %d, want 404", code)
}
for _, v := range []string{"1.1.0", "2.0.0"} {
tb := testCollection(t, "unkin", "e2e_hello", v)
if code, body := ansiblePublish(t, publish, tb, ""); code != http.StatusAccepted {
t.Fatalf("publish %s = %d: %s", v, code, body)
}
}
if code, body := ansiblePublish(t, publish, v1, ""); code != http.StatusConflict || !strings.Contains(body, "already exists") {
t.Errorf("identical republish = %d: %s, want 409", code, body)
}
if code, _ := ansiblePublish(t, publish, testCollection(t, "unkin", "e2e_hello", "1.0.0")[:20], ""); code != http.StatusBadRequest {
t.Errorf("truncated tarball = %d, want 400", code)
}
var root map[string]map[string]string
getJSON(t, base+"api/", &root)
if root["available_versions"]["v3"] != "v3/" {
t.Fatalf("root = %v", root)
}
var page struct {
Meta struct{ Count int }
Links struct{ Next, Previous *string }
Data []struct{ Version, Href string }
}
getJSON(t, base+"api/v3/collections/unkin/e2e_hello/versions/?limit=2", &page)
if page.Meta.Count != 3 || len(page.Data) != 2 || page.Data[0].Version != "2.0.0" || page.Links.Next == nil || page.Links.Previous != nil {
t.Fatalf("page 1 = %+v", page)
}
next := *page.Links.Next
if !strings.HasPrefix(next, "/api/v1/local/"+repo+"/") {
t.Fatalf("next = %q, want root-relative", next)
}
next = srv.URL + next
page.Links.Next = nil
getJSON(t, next, &page)
if len(page.Data) != 1 || page.Data[0].Version != "1.0.0" || page.Links.Next != nil {
t.Fatalf("page 2 (%s) = %+v", next, page)
}
var detail struct {
DownloadURL string `json:"download_url"`
Artifact struct{ Sha256 string }
Metadata struct{ Dependencies map[string]string }
Namespace struct{ Name string }
Collection struct{ Name string }
}
getJSON(t, page.Data[0].Href, &detail)
if detail.DownloadURL != base+"artifacts/unkin-e2e_hello-1.0.0.tar.gz" || detail.Artifact.Sha256 != sha256Hex(v1) ||
detail.Metadata.Dependencies["ansible.posix"] != ">=1.0.0" || detail.Namespace.Name != "unkin" || detail.Collection.Name != "e2e_hello" {
t.Fatalf("detail = %+v", detail)
}
if code, got := npmDo(t, http.MethodGet, detail.DownloadURL, ""); code != http.StatusOK || got != string(v1) {
t.Fatalf("download = %d (%d bytes)", code, len(got))
}
var coll map[string]any
getJSON(t, base+"api/v3/collections/unkin/e2e_hello/", &coll)
if coll["versions_url"] != base+"api/v3/collections/unkin/e2e_hello/versions/" || coll["updated_at"] == "" {
t.Fatalf("collection = %v", coll)
}
for _, p := range []string{"api/v3/collections/unkin/nope/", "api/v3/collections/unkin/e2e_hello/versions/9.0.0/", "_index/unkin/e2e_hello/1.0.0", "other"} {
if code, _ := npmDo(t, http.MethodGet, base+p, ""); code != http.StatusNotFound {
t.Errorf("GET %s = %d, want 404", p, code)
}
}
}
func TestAnsiblePublishRejects(t *testing.T) {
h, srv := npmTestServer(t)
repo := npmRepo(t, h, models.PackageAnsible)
publish := srv.URL + "/api/v1/local/" + repo + "/api/v3/artifacts/collections/"
tb := testCollection(t, "unkin", "rej", "1.0.0")
if code, _ := ansiblePublish(t, publish, tb, strings.Repeat("0", 64)); code != http.StatusBadRequest {
t.Errorf("sha256 mismatch = %d, want 400", code)
}
if code, _ := npmDo(t, http.MethodPost, publish, "raw"); code != http.StatusBadRequest {
t.Errorf("non-multipart = %d, want 400", code)
}
old := ansibleMaxPublishBytes
ansibleMaxPublishBytes = 64
if code, _ := ansiblePublish(t, publish, tb, ""); code != http.StatusRequestEntityTooLarge {
t.Errorf("oversize = %d, want 413", code)
}
ansibleMaxPublishBytes = old
generic := npmRepo(t, h, models.PackageGeneric)
if code, _ := ansiblePublish(t, srv.URL+"/api/v1/local/"+generic+"/api/v3/artifacts/collections/", tb, ""); code != http.StatusMethodNotAllowed {
t.Errorf("publish to generic local = %d, want 405", code)
}
if code, _ := ansiblePublish(t, publish, tb, ""); code != http.StatusAccepted {
t.Errorf("publish after rejects = %d, want 202", code)
}
}
func TestAnsibleLocalPerHost(t *testing.T) {
h, srv := npmTestServer(t)
repo := npmRepo(t, h, models.PackageAnsible)
if code, b := ansiblePublish(t, srv.URL+"/api/v1/local/"+repo+"/api/v3/artifacts/collections/", testCollection(t, "unkin", "host", "1.0.0"), ""); code != http.StatusAccepted {
t.Fatalf("publish = %d: %s", code, b)
}
routes := h.Routes()
path := "/local/" + repo + "/api/v3/collections/unkin/host/versions/1.0.0/"
r := httptest.NewRequest(http.MethodGet, path, nil)
r.Host = "x@evil.com"
w := httptest.NewRecorder()
routes.ServeHTTP(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("bad Host = %d, want 400", w.Code)
}
r = httptest.NewRequest(http.MethodGet, path, nil)
r.Host = "aa.example"
r.Header.Set("X-Forwarded-Proto", "https")
w = httptest.NewRecorder()
routes.ServeHTTP(w, r)
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"download_url":"https://aa.example/api/v1/local/`+repo+`/artifacts/unkin-host-1.0.0.tar.gz"`) {
t.Fatalf("detail = %d: %s", w.Code, w.Body)
}
if cc, vary := w.Header().Get("Cache-Control"), w.Header().Get("Vary"); cc != "no-store" || vary != "Host, X-Forwarded-Proto" {
t.Errorf("Cache-Control %q Vary %q", cc, vary)
}
}
func TestAnsibleVirtualOverLocals(t *testing.T) {
h, srv := npmTestServer(t)
h.virtualEngine = virtual.NewEngine(h.db, nil)
h.virtualEngine.AnsibleLocal = h.LocalAnsible
a, b := npmRepo(t, h, models.PackageAnsible), npmRepo(t, h, models.PackageAnsible)
for _, p := range []struct{ repo, version string }{{a, "1.0.0"}, {b, "1.0.0"}, {b, "2.0.0"}} {
if code, body := ansiblePublish(t, srv.URL+"/api/v1/local/"+p.repo+"/api/v3/artifacts/collections/", testCollection(t, "unkin", "virt", p.version), ""); code != http.StatusAccepted {
t.Fatalf("publish %s to %s = %d: %s", p.version, p.repo, code, body)
}
}
virt := "v" + a
if err := h.db.CreateVirtual(context.Background(), &models.Virtual{Name: virt, PackageType: models.PackageAnsible, Members: []string{a, b}}); err != nil {
t.Fatal(err)
}
base := srv.URL + "/api/v1/virtual/" + virt + "/"
var page struct {
Meta struct{ Count int }
Links struct{ Next *string }
Data []struct{ Version, Href string }
}
getJSON(t, base+"api/v3/collections/unkin/virt/versions/?limit=1", &page)
if page.Meta.Count != 2 || len(page.Data) != 1 || page.Data[0].Version != "1.0.0" || page.Links.Next == nil {
t.Fatalf("page 1 = %+v", page)
}
href := page.Data[0].Href
if !strings.HasPrefix(*page.Links.Next, "/api/v1/virtual/"+virt+"/") {
t.Fatalf("next = %q, want root-relative", *page.Links.Next)
}
getJSON(t, srv.URL+*page.Links.Next, &page)
if len(page.Data) != 1 || page.Data[0].Version != "2.0.0" || page.Links.Next != nil {
t.Fatalf("page 2 = %+v", page)
}
var detail struct {
DownloadURL string `json:"download_url"`
}
getJSON(t, href, &detail)
if detail.DownloadURL != base+a+"/artifacts/unkin-virt-1.0.0.tar.gz" {
t.Fatalf("download_url = %q, want the first member via the virtual", detail.DownloadURL)
}
noFollow := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
resp, err := noFollow.Get(detail.DownloadURL)
if err != nil {
t.Fatal(err)
}
_ = resp.Body.Close()
if resp.StatusCode != http.StatusFound || resp.Header.Get("Location") != srv.URL+"/api/v1/local/"+a+"/artifacts/unkin-virt-1.0.0.tar.gz" {
t.Fatalf("download = %d -> %q", resp.StatusCode, resp.Header.Get("Location"))
}
for _, p := range []string{"api/v3/collections/unkin/nope/", "notamember/x.tar.gz"} {
if code, _ := npmDo(t, http.MethodGet, base+p, ""); code != http.StatusNotFound {
t.Errorf("GET %s = %d, want 404", p, code)
}
}
}
+64
View File
@@ -0,0 +1,64 @@
package v1
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"net/url"
"git.unkin.net/unkin/artifactapi/internal/provider/ansible"
"git.unkin.net/unkin/artifactapi/internal/virtual"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
// serveVirtualAnsible serves the merged Galaxy v3 API and 302s downloads to
// the member owning that version.
func (h *ProxyHandler) serveVirtualAnsible(w http.ResponseWriter, r *http.Request, virt models.Virtual, path, proxyBaseURL string) {
ap := ansible.ParseAPIPath(path)
base := proxyBaseURL + "/api/v1/virtual/" + url.PathEscape(virt.Name)
var doc any
var err error
switch ap.Kind {
case ansible.APIRoot:
doc = ansible.Root()
case ansible.APIVersion:
var body []byte
if body, err = h.virtualEngine.AnsibleVersion(r.Context(), virt, ap.Namespace, ap.Name, ap.Version, proxyBaseURL); err == nil {
doc = json.RawMessage(body)
}
case ansible.APICollection, ansible.APIVersions:
var d *virtual.AnsibleDoc
if d, err = h.virtualEngine.AnsibleVersions(r.Context(), virt, ap.Namespace, ap.Name); err == nil {
if ap.Kind == ansible.APICollection {
doc = ansible.CollectionDoc(base, ap.Namespace, ap.Name, d.Refs)
} else {
limit, offset := ansible.Page(r.URL.Query())
doc = ansible.VersionsPage(base, ap.Namespace, ap.Name, d.Refs, limit, offset)
}
}
default:
loc, ok, err := h.virtualEngine.MemberRedirect(r.Context(), virt, path, r.URL.RawQuery, proxyBaseURL)
switch {
case err != nil:
ansibleError(w, http.StatusBadRequest, err.Error())
case !ok:
ansibleError(w, http.StatusNotFound, "not found")
default:
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):
ansibleError(w, http.StatusNotFound, "not found")
case err != nil:
slog.Error("virtual ansible fetch failed", "virtual", virt.Name, "path", path, "error", err)
ansibleError(w, http.StatusBadGateway, "bad gateway")
default:
w.Header().Set("X-Artifact-Source", "virtual")
writeAnsibleJSON(w, http.StatusOK, doc)
}
}
+58
View File
@@ -0,0 +1,58 @@
package v1
import (
"net/url"
"strings"
"testing"
"git.unkin.net/unkin/artifactapi/internal/provider"
)
type plainProvider struct{ provider.Provider }
type forwardingProvider struct{ plainProvider }
func digits(v string) (string, bool) {
if v == "" || strings.Trim(v, "0123456789") != "" {
return "", false
}
return strings.TrimLeft(v, "0"), true
}
func (forwardingProvider) ForwardedQuery(path string) map[string]provider.QueryValidator {
if strings.HasSuffix(path, ".tar.gz") {
return nil
}
return map[string]provider.QueryValidator{"limit": digits, "offset": digits}
}
func TestForwardQuery(t *testing.T) {
tests := []struct {
name string
prov provider.Provider
path string
query string
want string
}{
{"non-forwarder keeps path", plainProvider{}, "a/versions/", "limit=10&offset=5", "a/versions/"},
{"no query", forwardingProvider{}, "a/versions/", "", "a/versions/"},
{"allowlisted params sorted", forwardingProvider{}, "a/versions/", "offset=5&limit=10", "a/versions/?limit=10&offset=5"},
{"unknown params dropped", forwardingProvider{}, "a/versions/", "x=1&limit=10&cb=zz", "a/versions/?limit=10"},
{"only unknown params", forwardingProvider{}, "a/versions/", "x=1", "a/versions/"},
{"first value wins", forwardingProvider{}, "a/versions/", "limit=10&limit=20", "a/versions/?limit=10"},
{"invalid value dropped", forwardingProvider{}, "a/versions/", "limit=1%202&offset=5", "a/versions/?offset=5"},
{"value canonicalised", forwardingProvider{}, "a/versions/", "limit=0010", "a/versions/?limit=10"},
{"path without allowlist", forwardingProvider{}, "a/b-1.0.0.tar.gz", "limit=10", "a/b-1.0.0.tar.gz"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
q, err := url.ParseQuery(tt.query)
if err != nil {
t.Fatal(err)
}
if got := forwardQuery(tt.prov, tt.path, q); got != tt.want {
t.Errorf("forwardQuery = %q, want %q", got, tt.want)
}
})
}
}
+4
View File
@@ -64,6 +64,10 @@ func (h *ProxyHandler) lockGemVersion(w http.ResponseWriter, r *http.Request, re
}
func (h *ProxyHandler) handleLocalPost(w http.ResponseWriter, r *http.Request) {
if isAnsiblePublish(chi.URLParam(r, "*")) {
h.handleAnsiblePublish(w, r)
return
}
if chi.URLParam(r, "*") != "api/v1/gems" {
gemsText(w, http.StatusNotFound, "not found")
return
+4 -3
View File
@@ -231,8 +231,9 @@ func TestGemsLockTimeoutIs503(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if ok, err := redis.AcquireLock(context.Background(), repo, "_gemlock/busy-1.0.0", time.Minute); !ok || err != nil {
t.Fatalf("hold lock = %v %v", ok, err)
token, err := redis.AcquireLock(context.Background(), repo, "_gemlock/busy-1.0.0", time.Minute)
if token == "" || err != nil {
t.Fatalf("hold lock = %q %v", token, err)
}
prev := gemsLockWait
gemsLockWait = 100 * time.Millisecond
@@ -244,7 +245,7 @@ func TestGemsLockTimeoutIs503(t *testing.T) {
if code, _ := npmDo(t, http.MethodDelete, base+"api/v1/gems/yank?gem_name=busy&version=1.0.0", ""); code != http.StatusServiceUnavailable {
t.Fatalf("yank under held lock = %d, want 503", code)
}
if err := redis.ReleaseLock(context.Background(), repo, "_gemlock/busy-1.0.0"); err != nil {
if err := redis.ReleaseLock(context.Background(), repo, "_gemlock/busy-1.0.0", token); err != nil {
t.Fatal(err)
}
if code, body := npmDo(t, http.MethodPost, base+"api/v1/gems", string(testGemFile(t, "busy", "1.0.0", "ruby"))); code != http.StatusOK {
+33
View File
@@ -0,0 +1,33 @@
package v1
import (
"errors"
"log/slog"
"net/http"
"git.unkin.net/unkin/artifactapi/internal/virtual"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
// serveVirtualGoProxy serves the merged @v/list and 302s @latest and version
// files to the member owning that version.
func (h *ProxyHandler) serveVirtualGoProxy(w http.ResponseWriter, r *http.Request, virt models.Virtual, path string) {
body, loc, err := h.virtualEngine.GoProxy(r.Context(), virt, path, r.URL.RawQuery)
switch {
case errors.Is(err, virtual.ErrNotFound):
http.Error(w, "not found", http.StatusNotFound)
case errors.Is(err, virtual.ErrBadPath):
http.Error(w, err.Error(), http.StatusBadRequest)
case err != nil:
slog.Error("virtual goproxy fetch failed", "virtual", virt.Name, "path", path, "error", err)
http.Error(w, "bad gateway", http.StatusBadGateway)
case loc != "":
w.Header().Set("Cache-Control", "no-store")
http.Redirect(w, r, loc, http.StatusFound)
default:
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("X-Artifact-Source", "virtual")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(body)
}
}
+41 -1
View File
@@ -8,6 +8,7 @@ import (
"log/slog"
"net"
"net/http"
"net/url"
"strconv"
"strings"
@@ -16,6 +17,7 @@ import (
v2 "git.unkin.net/unkin/artifactapi/internal/api/v2"
"git.unkin.net/unkin/artifactapi/internal/database"
"git.unkin.net/unkin/artifactapi/internal/provider"
"git.unkin.net/unkin/artifactapi/internal/provider/ansible"
"git.unkin.net/unkin/artifactapi/internal/proxy"
"git.unkin.net/unkin/artifactapi/internal/storage"
"git.unkin.net/unkin/artifactapi/internal/virtual"
@@ -115,7 +117,7 @@ func (h *ProxyHandler) handleProxy(w http.ResponseWriter, r *http.Request) {
}
func (h *ProxyHandler) serveFetch(w http.ResponseWriter, r *http.Request, remote *models.Remote, prov provider.Provider, path string) {
result, err := h.engine.Fetch(r.Context(), *remote, path, prov, r.Header)
result, err := h.engine.Fetch(r.Context(), *remote, forwardQuery(prov, path, r.URL.Query()), prov, r.Header)
if err != nil {
var proxyErr *proxy.ProxyError
if errors.As(err, &proxyErr) {
@@ -209,6 +211,14 @@ func (h *ProxyHandler) handleVirtual(w http.ResponseWriter, r *http.Request) {
h.serveVirtualGems(w, r, *virt, path, proxyBaseURL)
return
}
if virt.PackageType == models.PackageAnsible {
h.serveVirtualAnsible(w, r, *virt, path, proxyBaseURL)
return
}
if virt.PackageType == models.PackageGoProxy {
h.serveVirtualGoProxy(w, r, *virt, path)
return
}
loc, ok, err := h.virtualEngine.MemberRedirect(r.Context(), *virt, path, r.URL.RawQuery, proxyBaseURL)
if err != nil {
@@ -274,6 +284,17 @@ func (h *ProxyHandler) handleLocal(w http.ResponseWriter, r *http.Request) {
}
}
if remote.PackageType == models.PackageAnsible {
if ap := ansible.ParseAPIPath(path); ap.Kind != ansible.APIUnknown {
h.serveLocalAnsible(w, r, remote, ap)
return
}
if !strings.HasPrefix(path, ansible.ArtifactPrefix) {
http.Error(w, "not found", http.StatusNotFound)
return
}
}
prov, _ := provider.Get(remote.PackageType)
if indexer, ok := prov.(provider.LocalIndexer); ok {
if indexer.ServeLocalIndex(w, r, h.db, remote.Name, path) {
@@ -325,6 +346,25 @@ func Scheme(r *http.Request) string {
return "http"
}
// forwardQuery appends the provider's validated query params to path in a
// canonical (sorted, single-valued) form, since the result is the cache key.
func forwardQuery(prov provider.Provider, path string, query url.Values) string {
f, ok := prov.(provider.QueryForwarder)
if !ok {
return path
}
kept := url.Values{}
for name, valid := range f.ForwardedQuery(path) {
if v, ok := valid(query.Get(name)); ok {
kept.Set(name, v)
}
}
if len(kept) == 0 {
return path
}
return path + "?" + kept.Encode()
}
// rewriteServed rewrites a cached body for the requesting host and marks the
// response uncacheable by shared caches, since it varies per Host.
func rewriteServed(w http.ResponseWriter, r *http.Request, sr provider.ServeRewriter, remote models.Remote, src io.Reader) ([]byte, bool) {
+38 -1
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"io"
"net/http"
"os"
"github.com/go-chi/chi/v5"
@@ -83,7 +84,22 @@ func (h *LocalHandler) uploadValidated(w http.ResponseWriter, r *http.Request, r
return
}
result, err := h.cas.Store(r.Context(), r.Body, contentType)
var body io.Reader = r.Body
if cv, ok := prov.(provider.ContentValidator); ok {
f, err := spoolBody(r.Body)
if err != nil {
http.Error(w, fmt.Sprintf("read upload: %v", err), http.StatusInternalServerError)
return
}
defer discard(f)
if err := cv.ValidateContent(storagePath, f.Name()); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
body = f
}
result, err := h.cas.Store(r.Context(), body, contentType)
if err != nil {
http.Error(w, fmt.Sprintf("store failed: %v", err), http.StatusInternalServerError)
return
@@ -110,6 +126,27 @@ func (h *LocalHandler) uploadValidated(w http.ResponseWriter, r *http.Request, r
writeJSON(w, http.StatusCreated, uploader.UploadResponse(storagePath, result.ContentHash, result.SizeBytes))
}
func spoolBody(r io.Reader) (*os.File, error) {
f, err := os.CreateTemp("", "artifactapi-upload-*")
if err != nil {
return nil, err
}
if _, err := io.Copy(f, r); err != nil {
discard(f)
return nil, err
}
if _, err := f.Seek(0, io.SeekStart); err != nil {
discard(f)
return nil, err
}
return f, nil
}
func discard(f *os.File) {
_ = f.Close()
_ = os.Remove(f.Name())
}
func (h *LocalHandler) uploadGeneric(w http.ResponseWriter, r *http.Request, remote *models.Remote, filePath string) {
existing, err := h.db.GetLocalFile(r.Context(), remote.Name, filePath)
if err != nil {
+55
View File
@@ -0,0 +1,55 @@
package v2
import (
"context"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"git.unkin.net/unkin/artifactapi/internal/database"
_ "git.unkin.net/unkin/artifactapi/internal/provider/goproxy"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
// TestLocalUploadContentRejected verifies a ContentValidator provider rejects
// bad bytes with 400 before anything is stored.
func TestLocalUploadContentRejected(t *testing.T) {
if testDSN == "" {
t.Skip("Docker unavailable")
}
ctx := context.Background()
db, err := database.New(testDSN)
if err != nil {
t.Fatal(err)
}
defer db.Close()
const repo = "go-local-validate"
if err := db.CreateRemote(ctx, &models.Remote{Name: repo, PackageType: models.PackageGoProxy, RepoType: models.RepoTypeLocal}); err != nil {
t.Fatal(err)
}
router := chi.NewRouter()
router.Route("/remotes/{name}/files", func(r chi.Router) {
r.Put("/*", NewLocalHandler(db, nil).Routes().ServeHTTP)
})
cases := map[string]string{
"example.unkin.net/hello/@v/v0.1.0.zip": "not a zip",
"example.unkin.net/hello/@v/v0.1.0.mod": "module example.unkin.net/other\n",
"example.unkin.net/hello/@v/v0.1.0.info": `{"Version":"v9.9.9"}`,
"example.unkin.net/Hello/@v/v0.1.0.mod": "module example.unkin.net/Hello\n",
}
for path, body := range cases {
w := httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest("PUT", "/remotes/"+repo+"/files/"+path, strings.NewReader(body)))
if w.Code != 400 {
t.Errorf("%s: status %d, want 400 (%s)", path, w.Code, w.Body.String())
}
if f, _ := db.GetLocalFile(ctx, repo, path); f != nil {
t.Errorf("%s: rejected upload was recorded", path)
}
}
}
+43 -7
View File
@@ -61,18 +61,54 @@ func TestTTL(t *testing.T) {
func TestLock(t *testing.T) {
requireRedis(t)
ctx := context.Background()
ok, err := testRedis.AcquireLock(ctx, "r", "lockpath", time.Minute)
if err != nil || !ok {
t.Fatalf("first acquire should succeed: %v %v", ok, err)
token, err := testRedis.AcquireLock(ctx, "r", "lockpath", time.Minute)
if err != nil || token == "" {
t.Fatalf("first acquire should succeed: %q %v", token, err)
}
if ok, _ := testRedis.AcquireLock(ctx, "r", "lockpath", time.Minute); ok {
if again, _ := testRedis.AcquireLock(ctx, "r", "lockpath", time.Minute); again != "" {
t.Error("second acquire should fail while held")
}
if err := testRedis.ReleaseLock(ctx, "r", "lockpath"); err != nil {
if err := testRedis.ReleaseLock(ctx, "r", "lockpath", "not-the-owner"); err != nil {
t.Fatal(err)
}
if ok, _ := testRedis.AcquireLock(ctx, "r", "lockpath", time.Minute); !ok {
t.Error("acquire should succeed after release")
if again, _ := testRedis.AcquireLock(ctx, "r", "lockpath", time.Minute); again != "" {
t.Error("release with a foreign token must not drop the lock")
}
if err := testRedis.ReleaseLock(ctx, "r", "lockpath", token); err != nil {
t.Fatal(err)
}
next, _ := testRedis.AcquireLock(ctx, "r", "lockpath", time.Minute)
if next == "" {
t.Fatal("acquire should succeed after release")
}
if next == token {
t.Error("tokens must differ between holders")
}
}
func TestStaleReleaseKeepsNextHolder(t *testing.T) {
requireRedis(t)
ctx := context.Background()
stale, err := testRedis.AcquireLock(ctx, "r", "stale", 50*time.Millisecond)
if err != nil || stale == "" {
t.Fatalf("acquire: %q %v", stale, err)
}
time.Sleep(100 * time.Millisecond)
fresh, err := testRedis.AcquireLock(ctx, "r", "stale", time.Minute)
if err != nil || fresh == "" {
t.Fatalf("acquire after expiry: %q %v", fresh, err)
}
if err := testRedis.ReleaseLock(ctx, "r", "stale", stale); err != nil {
t.Fatal(err)
}
if again, _ := testRedis.AcquireLock(ctx, "r", "stale", time.Minute); again != "" {
t.Fatal("stale holder's release dropped the second holder's lock")
}
if err := testRedis.ReleaseLock(ctx, "r", "stale", fresh); err != nil {
t.Fatal(err)
}
if again, _ := testRedis.AcquireLock(ctx, "r", "stale", time.Minute); again == "" {
t.Error("owner release should free the lock")
}
}
+14 -5
View File
@@ -2,6 +2,7 @@ package cache
import (
"context"
"crypto/rand"
"fmt"
"strings"
"time"
@@ -46,15 +47,23 @@ func (r *Redis) CheckTTL(ctx context.Context, remote, path string) (bool, error)
return exists > 0, nil
}
func (r *Redis) AcquireLock(ctx context.Context, remote, path string, ttl time.Duration) (bool, error) {
// AcquireLock returns the owner token on success and "" when the lock is held.
func (r *Redis) AcquireLock(ctx context.Context, remote, path string, ttl time.Duration) (string, error) {
key := fmt.Sprintf("lock:%s:%s", remote, path)
ok, err := r.client.SetNX(ctx, key, "1", ttl).Result()
return ok, err
token := rand.Text()
ok, err := r.client.SetNX(ctx, key, token, ttl).Result()
if err != nil || !ok {
return "", err
}
return token, nil
}
func (r *Redis) ReleaseLock(ctx context.Context, remote, path string) error {
var releaseScript = redis.NewScript(`if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end`)
// ReleaseLock deletes the lock only while token still owns it.
func (r *Redis) ReleaseLock(ctx context.Context, remote, path, token string) error {
key := fmt.Sprintf("lock:%s:%s", remote, path)
return r.client.Del(ctx, key).Err()
return releaseScript.Run(ctx, r.client, []string{key}, token).Err()
}
// SetETag stores etag for path, or drops the stored one when etag is empty.
+69
View File
@@ -207,3 +207,72 @@ func TestGitHubSyncLeaseFailureBackoff(t *testing.T) {
t.Fatal("claimed inside freshness window after a successful sync")
}
}
// Deleting a remote drops its sync state, so a recreated remote of the same name
// starts clean instead of inheriting the old ETag, sync time and retry state.
func TestGitHubSyncStateDeletedWithRemote(t *testing.T) {
requireDB(t)
for _, tc := range []struct {
table string
pt models.PackageType
claim func(name string) (bool, string, error)
release func(name string, res provider.SyncResult) error
}{
{"github_rpm_sync_state", models.PackageGitHubRPM,
func(n string) (bool, string, error) {
return testDB.ClaimGitHubSyncLease(ctx(), n, "r1", time.Hour, time.Minute)
},
func(n string, res provider.SyncResult) error {
return testDB.ReleaseGitHubSyncLease(ctx(), n, "r1", res)
}},
{"github_deb_sync_state", models.PackageGitHubDeb,
func(n string) (bool, string, error) {
return testDB.ClaimGitHubDebSyncLease(ctx(), n, "r1", time.Hour, time.Minute)
},
func(n string, res provider.SyncResult) error {
return testDB.ReleaseGitHubDebSyncLease(ctx(), n, "r1", res)
}},
{"github_alpine_sync_state", models.PackageGitHubAlpine,
func(n string) (bool, string, error) {
return testDB.ClaimGitHubAlpineSyncLease(ctx(), n, "r1", time.Hour, time.Minute)
},
func(n string, res provider.SyncResult) error {
return testDB.ReleaseGitHubAlpineSyncLease(ctx(), n, "r1", res)
}},
} {
t.Run(tc.table, func(t *testing.T) {
name := "gh-recreate-" + time.Now().Format("150405.000000")
create := func() {
t.Helper()
if err := testDB.CreateRemote(ctx(), &models.Remote{
Name: name, PackageType: tc.pt, RepoType: models.RepoTypeRemote,
BaseURL: "https://api.github.com/repos/acme/tools", ReleasesRemote: "github", MutableTTL: 3600,
}); err != nil {
t.Fatalf("create remote: %v", err)
}
}
create()
if ok, _, err := tc.claim(name); err != nil || !ok {
t.Fatalf("claim: ok=%v err=%v", ok, err)
}
if err := tc.release(name, provider.SyncResult{Etag: `"old"`}); err != nil {
t.Fatalf("release: %v", err)
}
if err := testDB.DeleteRemote(ctx(), name); err != nil {
t.Fatalf("delete remote: %v", err)
}
var rows int
if err := testDB.Pool.QueryRow(ctx(), `SELECT count(*) FROM `+tc.table+` WHERE remote_name = $1`, name).Scan(&rows); err != nil || rows != 0 {
t.Fatalf("sync state left after delete: rows=%d err=%v", rows, err)
}
create()
ok, etag, err := tc.claim(name)
if err != nil || !ok || etag != "" {
t.Fatalf("recreated remote claim ok=%v etag=%q err=%v, want a fresh claim with no etag", ok, etag, err)
}
})
}
}
+3
View File
@@ -0,0 +1,3 @@
package all
import _ "git.unkin.net/unkin/artifactapi/internal/provider/ansible"
+14 -13
View File
@@ -372,6 +372,9 @@ func (p *GitHubProvider) scanWithState(ctx context.Context, remote models.Remote
return newEtag, false, err
}
// A transiently failed asset fails the scan so the ETag is not advanced and
// the retry re-derives it; an invalid package is skipped as no retry fixes it.
var failed []error
seen := map[string]bool{}
for _, rel := range releases {
if rel.Draft {
@@ -400,10 +403,14 @@ func (p *GitHubProvider) scanWithState(ctx context.Context, remote models.Remote
meta, err := p.deriveAsset(ctx, remote, asset, fp)
if err != nil {
slog.Warn("github_alpine: derive asset failed", "remote", remote.Name, "asset", asset.Name, "error", err)
if !errors.Is(err, provider.ErrInvalidPackage) {
failed = append(failed, fmt.Errorf("%s: %w", asset.Name, err))
}
continue
}
if err := inserter.InsertAlpineMetadata(ctx, meta); err != nil {
slog.Error("github_alpine: insert metadata failed", "remote", remote.Name, "asset", asset.Name, "error", err)
failed = append(failed, fmt.Errorf("%s: %w", asset.Name, err))
continue
}
slog.Info("github_alpine: derived asset", "remote", remote.Name, "name", meta.Name, "version", meta.Version, "arch", meta.Arch)
@@ -415,7 +422,7 @@ func (p *GitHubProvider) scanWithState(ctx context.Context, remote models.Remote
_ = deleter.DeleteAlpineMetadata(ctx, remote.Name, fp)
}
}
return newEtag, true, nil
return newEtag, true, errors.Join(failed...)
}
type ghRelease struct {
@@ -496,7 +503,7 @@ func (p *GitHubProvider) deriveAsset(ctx context.Context, remote models.Remote,
return nil, err
}
if meta.Name == "" || meta.Arch == "" {
return nil, errors.New(".PKGINFO missing pkgname/arch")
return nil, fmt.Errorf("%w: .PKGINFO missing pkgname/arch", provider.ErrInvalidPackage)
}
meta.RepoName = remote.Name
@@ -526,13 +533,13 @@ func (p *GitHubProvider) fetchPkginfo(ctx context.Context, remote models.Remote,
}
meta, complete, perr := pkginfoFromPrefix(body)
if perr != nil {
return nil, fmt.Errorf("parse apk .PKGINFO: %w", perr)
return nil, fmt.Errorf("%w: parse apk .PKGINFO: %w", provider.ErrInvalidPackage, perr)
}
if complete {
return meta, nil
}
if full || n >= p.headerMax {
return nil, fmt.Errorf(".PKGINFO not found within %d bytes of %s", n, downloadURL)
return nil, fmt.Errorf("%w: .PKGINFO not found within %d bytes of %s", provider.ErrInvalidPackage, n, downloadURL)
}
n *= 2
if n > p.headerMax {
@@ -594,7 +601,7 @@ func pkginfoFromPrefix(prefix []byte) (meta *provider.AlpineMetadata, complete b
}
// rangeGet returns the first n bytes of downloadURL. full is true when the
// response body was shorter than n (i.e. we already have the whole object).
// response holds the whole object.
func (p *GitHubProvider) rangeGet(ctx context.Context, remote models.Remote, downloadURL string, n int64) ([]byte, bool, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
if err != nil {
@@ -616,15 +623,9 @@ func (p *GitHubProvider) rangeGet(ctx context.Context, remote models.Remote, dow
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {
return nil, false, fmt.Errorf("range GET %s: status %d", downloadURL, resp.StatusCode)
return nil, false, fmt.Errorf("range GET: %w", provider.AssetStatusError(downloadURL, resp))
}
body, err := io.ReadAll(io.LimitReader(resp.Body, n))
if err != nil {
return nil, false, err
}
full := int64(len(body)) < n
return body, full, nil
return provider.ReadPrefix(resp, n)
}
// assetPath is the package's location relative to github.com — the path the
+22 -3
View File
@@ -8,6 +8,7 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -88,15 +89,17 @@ type githubFixture struct {
notModHit int
releaseAuth string
assetAuth string
assetFail map[string]int // asset filename -> downloads left to answer 500
mu sync.Mutex
}
func newGitHubFixture(t *testing.T, withDigest bool) *githubFixture {
t.Helper()
f := &githubFixture{
apkBytes: map[string][]byte{},
rangeHit: map[string]int{},
fullHit: map[string]int{},
apkBytes: map[string][]byte{},
rangeHit: map[string]int{},
fullHit: map[string]int{},
assetFail: map[string]int{},
}
f.apkBytes["demo-1.2.3-r0.apk"] = testsupport.MinimalApk("demo", "1.2.3-r0", "x86_64")
@@ -146,6 +149,12 @@ func newGitHubFixture(t *testing.T, withDigest bool) *githubFixture {
}
rng := r.Header.Get("Range")
f.mu.Lock()
if f.assetFail[name] > 0 {
f.assetFail[name]--
f.mu.Unlock()
http.Error(w, "boom", http.StatusInternalServerError)
return
}
f.assetAuth = r.Header.Get("Authorization")
if rng != "" {
f.rangeHit[name]++
@@ -495,3 +504,13 @@ func readAPKIndex(t *testing.T, gzBytes []byte) string {
}
}
}
func TestGitHubDeriveAssetMissingPkgnameIsInvalid(t *testing.T) {
fx := newGitHubFixture(t, true)
fx.apkBytes["nameless-1-r0.apk"] = testsupport.MinimalApk("", "1-r0", "x86_64")
asset := ghAsset{BrowserDownloadURL: fx.srv.URL + "/acme/tools/releases/download/v1.2.3/nameless-1-r0.apk"}
_, err := newTestProvider().deriveAsset(context.Background(), fx.remote(), asset, "fp")
if !errors.Is(err, provider.ErrInvalidPackage) {
t.Fatalf("want ErrInvalidPackage, got %v", err)
}
}
+55
View File
@@ -308,3 +308,58 @@ func TestSyncerPrimeBypassesRecencyPeriodicDoesNot(t *testing.T) {
t.Fatalf("periodic scan ran inside recency window: %d -> %d releases calls", releasesAfterPrime, fx.releasesHit)
}
}
// A transiently failed asset fails the scan: the ETag is not advanced, a retry
// is scheduled, and the retry against the unchanged release list derives it. An
// invalid package is skipped instead, since no retry can fix it.
func TestSyncerFailedAssetRetry(t *testing.T) {
for _, tc := range []struct {
name string
corrupt bool
wantRetry bool
}{
{name: "transient", wantRetry: true},
{name: "invalid package", corrupt: true},
} {
t.Run(tc.name, func(t *testing.T) {
fx := newGitHubFixture(t, true)
fx.etag = `"v1"`
if tc.corrupt {
fx.apkBytes["other-9-r0.apk"] = []byte("not a package")
} else {
fx.apkBytes["other-9-r0.apk"] = testsupport.MinimalApk("other", "9-r0", "aarch64")
fx.assetFail["other-9-r0.apk"] = 1
}
store := newFakeSyncStore()
s := newSyncer(store, newTestProvider(), testSyncConfig())
remote := fx.remote()
bg := context.Background()
s.process(bg, syncJob{remote: remote, prime: true})
_, pending := store.retryAt[remote.Name]
if pending != tc.wantRetry {
t.Fatalf("retry pending=%v, want %v", pending, tc.wantRetry)
}
if _, saved := store.etags[remote.Name]; saved == tc.wantRetry {
t.Fatalf("etag saved=%v after first scan", saved)
}
if rows, _ := store.ListAlpineMetadataEntries(bg, remote.Name); len(rows) != 1 {
t.Fatalf("healthy asset not served: %d rows", len(rows))
}
if !tc.wantRetry {
return
}
store.mu.Lock()
store.retryAt[remote.Name] = time.Now().Add(-time.Second)
store.mu.Unlock()
s.process(bg, syncJob{remote: remote})
if _, pending := store.retryAt[remote.Name]; pending || store.etags[remote.Name] != `"v1"` {
t.Fatalf("retry not recorded: pending=%v etag=%q", pending, store.etags[remote.Name])
}
if rows, _ := store.ListAlpineMetadataEntries(bg, remote.Name); len(rows) != 2 {
t.Fatalf("retry did not derive the failed asset: %d rows", len(rows))
}
})
}
}
+139
View File
@@ -0,0 +1,139 @@
// Package ansible proxies an Ansible Galaxy server (v3 collections API):
// mutable JSON metadata whose links are rewritten back to the remote, plus
// immutable collection tarballs.
package ansible
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/url"
"strconv"
"strings"
"git.unkin.net/unkin/artifactapi/internal/auth"
"git.unkin.net/unkin/artifactapi/internal/provider"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
func init() {
provider.Register(&Provider{})
}
type Provider struct{}
func (p *Provider) Type() models.PackageType { return models.PackageAnsible }
func (p *Provider) Classify(path string) provider.Mutability {
if isArtifact(path) {
return provider.Immutable
}
return provider.Mutable
}
func (p *Provider) ContentType(path string) string {
if isArtifact(path) {
return "application/gzip"
}
return "application/json"
}
func (p *Provider) UpstreamURL(remote models.Remote, path string) string {
return strings.TrimRight(remote.BaseURL, "/") + "/" + strings.TrimLeft(path, "/")
}
const maxPageLimit = 100
// ForwardedQuery keeps limit/offset pagination on metadata paths only.
func (p *Provider) ForwardedQuery(path string) map[string]provider.QueryValidator {
if isArtifact(path) {
return nil
}
return map[string]provider.QueryValidator{"limit": pageLimit, "offset": pageOffset}
}
func pageOffset(v string) (string, bool) {
n, err := strconv.ParseUint(v, 10, 32)
return strconv.FormatUint(n, 10), err == nil
}
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, maxPageLimit), 10), true
}
// RewriteResponse points href, download_url and links.* at the remote. Links
// stay root-relative, which ansible-galaxy joins onto the server URL, so the
// cached body is independent of the request host.
func (p *Provider) RewriteResponse(body []byte, remote models.Remote, _ string) ([]byte, error) {
dec := json.NewDecoder(bytes.NewReader(body))
dec.UseNumber()
var doc any
if err := dec.Decode(&doc); err != nil {
return nil, nil
}
r := rewriter{
prefix: provider.RemoteMountPath + url.PathEscape(remote.Name) + "/",
base: strings.TrimRight(remote.BaseURL, "/") + "/",
}
r.walk(doc)
var out bytes.Buffer
enc := json.NewEncoder(&out)
enc.SetEscapeHTML(false)
if err := enc.Encode(doc); err != nil {
return nil, err
}
return out.Bytes(), nil
}
func (p *Provider) AuthHeaders(_ context.Context, remote models.Remote) (http.Header, error) {
return auth.BasicHeaders(remote), nil
}
type rewriter struct{ prefix, base string }
func (r rewriter) walk(v any) {
switch v := v.(type) {
case []any:
for _, e := range v {
r.walk(e)
}
case map[string]any:
for k, e := range v {
switch k {
case "href", "download_url":
v[k] = r.link(e)
case "links":
if links, ok := e.(map[string]any); ok {
for lk, l := range links {
links[lk] = r.link(l)
}
continue
}
}
r.walk(v[k])
}
}
}
func (r rewriter) link(v any) any {
s, ok := v.(string)
switch {
case !ok:
return v
case strings.HasPrefix(s, "/api/"):
return r.prefix + s[1:]
case strings.HasPrefix(s, r.base):
return r.prefix + strings.TrimPrefix(s, r.base)
}
return s
}
func isArtifact(path string) bool {
path, _, _ = strings.Cut(path, "?")
return strings.Contains(path, "/artifacts/") && strings.HasSuffix(path, ".tar.gz")
}
+150
View File
@@ -0,0 +1,150 @@
package ansible
import (
"encoding/json"
"testing"
"git.unkin.net/unkin/artifactapi/internal/provider"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
const tarball = "api/v3/plugin/ansible/content/published/collections/artifacts/ansible-posix-2.0.0.tar.gz"
func TestClassify(t *testing.T) {
p := &Provider{}
tests := []struct {
path string
want provider.Mutability
}{
{"api/", provider.Mutable},
{"api/v3/collections/community/general/versions/?limit=100", provider.Mutable},
{"api/v3/collections/community/general/versions/?x=.tar.gz", provider.Mutable},
{"api/v3/collections/ansible/posix/versions/2.0.0/", provider.Mutable},
{"api/v3/collections/ansible/posix/readme.tar.gz", provider.Mutable},
{tarball, provider.Immutable},
{tarball + "?limit=1", provider.Immutable},
}
for _, tt := range tests {
if got := p.Classify(tt.path); got != tt.want {
t.Errorf("Classify(%q) = %v, want %v", tt.path, got, tt.want)
}
}
}
func TestContentTypeIgnoresQuery(t *testing.T) {
if got := (&Provider{}).ContentType("api/v3/collections/a/b/versions/?x=.tar.gz"); got != "application/json" {
t.Errorf("ContentType = %q, want application/json", got)
}
}
func TestForwardedQuery(t *testing.T) {
p := &Provider{}
if got := p.ForwardedQuery(tarball); got != nil {
t.Errorf("tarball ForwardedQuery = %v, want nil", got)
}
q := p.ForwardedQuery("api/v3/collections/a/b/versions/")
if len(q) != 2 || q["limit"] == nil || q["offset"] == nil {
t.Fatalf("metadata ForwardedQuery keys = %v, want limit and offset", q)
}
tests := []struct {
param, in, want string
ok bool
}{
{"limit", "10", "10", true},
{"limit", "007", "7", true},
{"limit", "100", "100", true},
{"limit", "5000", "100", true},
{"limit", "0", "", false},
{"limit", "", "", false},
{"limit", "-1", "", false},
{"limit", "+5", "", false},
{"limit", "1e3", "", false},
{"limit", "10 ", "", false},
{"offset", "0", "0", true},
{"offset", "000", "0", true},
{"offset", "0100", "100", true},
{"offset", "abc", "", false},
{"offset", "99999999999999999999", "", false},
}
for _, tt := range tests {
got, ok := q[tt.param](tt.in)
if ok != tt.ok || (ok && got != tt.want) {
t.Errorf("%s(%q) = %q, %v, want %q, %v", tt.param, tt.in, got, ok, tt.want, tt.ok)
}
}
}
func rewrite(t *testing.T, remote models.Remote, body string) map[string]any {
t.Helper()
out, err := (&Provider{}).RewriteResponse([]byte(body), remote, "")
if err != nil {
t.Fatal(err)
}
var got map[string]any
if err := json.Unmarshal(out, &got); err != nil {
t.Fatalf("rewritten body is not JSON: %v: %s", err, out)
}
return got
}
func TestRewriteResponse(t *testing.T) {
remote := models.Remote{Name: "galaxy", BaseURL: "https://galaxy.ansible.com/"}
got := rewrite(t, remote, `{
"links": {"next": "/api/v3/collections/a/b/versions/?limit=10&offset=10", "previous": null},
"data": [{
"href": "/api/v3/collections/a/b/versions/1.0.0/",
"download_url": "https://galaxy.ansible.com/api/v3/plugin/ansible/content/published/collections/artifacts/a-b-1.0.0.tar.gz",
"description": "/api/ is where it lives",
"docs": "/api/v3/not-a-link/",
"metadata": {"repository": "https://github.com/a/b", "homepage": "https://galaxy.ansible.com/ui/"}
}]
}`)
pre := "/api/v1/remote/galaxy/"
links := got["links"].(map[string]any)
if want := pre + "api/v3/collections/a/b/versions/?limit=10&offset=10"; links["next"] != want {
t.Errorf("links.next = %v, want %q", links["next"], want)
}
if links["previous"] != nil {
t.Errorf("links.previous = %v, want null", links["previous"])
}
item := got["data"].([]any)[0].(map[string]any)
if want := pre + "api/v3/collections/a/b/versions/1.0.0/"; item["href"] != want {
t.Errorf("href = %v, want %q", item["href"], want)
}
if want := pre + "api/v3/plugin/ansible/content/published/collections/artifacts/a-b-1.0.0.tar.gz"; item["download_url"] != want {
t.Errorf("download_url = %v, want %q", item["download_url"], want)
}
for k, want := range map[string]string{"description": "/api/ is where it lives", "docs": "/api/v3/not-a-link/"} {
if item[k] != want {
t.Errorf("%s rewritten to %v", k, item[k])
}
}
meta := item["metadata"].(map[string]any)
if meta["repository"] != "https://github.com/a/b" || meta["homepage"] != "https://galaxy.ansible.com/ui/" {
t.Errorf("unrelated URLs rewritten: %v", meta)
}
}
func TestRewriteResponseEscapesRemoteName(t *testing.T) {
got := rewrite(t, models.Remote{Name: `a"b/c`, BaseURL: "https://g/"}, `{"href":"/api/x/"}`)
if want := "/api/v1/remote/a%22b%2Fc/api/x/"; got["href"] != want {
t.Errorf("href = %v, want %q", got["href"], want)
}
}
func TestRewriteResponseKeepsLargeNumbers(t *testing.T) {
out, err := (&Provider{}).RewriteResponse([]byte(`{"n":12345678901234567890}`), models.Remote{Name: "g"}, "")
if err != nil {
t.Fatal(err)
}
if want := "{\"n\":12345678901234567890}\n"; string(out) != want {
t.Errorf("got %q, want %q", out, want)
}
}
func TestRewriteResponseSkipsNonJSON(t *testing.T) {
out, err := (&Provider{}).RewriteResponse([]byte("<html>"), models.Remote{Name: "g"}, "")
if err != nil || out != nil {
t.Errorf("got %q, %v; want nil, nil", out, err)
}
}
+347
View File
@@ -0,0 +1,347 @@
package ansible
import (
"archive/tar"
"compress/gzip"
"encoding/json"
"errors"
"fmt"
"io"
"net/url"
"path"
"regexp"
"strconv"
"strings"
"time"
)
// Local ansible repos speak the Galaxy v3 publish and install API. Per version:
// artifacts/<ns>-<name>-<version>.tar.gz collection tarball (immutable)
// _index/<ns>/<name>/<version> Entry JSON; gates visibility
const (
IndexPrefix = "_index/"
ArtifactPrefix = "artifacts/"
PublishPath = "api/v3/artifacts/collections/"
)
var (
NameRe = regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`)
VersionRe = regexp.MustCompile(`^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$`)
)
var (
MaxManifestBytes int64 = 1 << 20
MaxScanBytes int64 = 512 << 20
)
// Entry is one published collection version.
type Entry struct {
Namespace string `json:"namespace"`
Name string `json:"name"`
Version string `json:"version"`
SHA256 string `json:"sha256"`
Size int64 `json:"size"`
Metadata json.RawMessage `json:"metadata"`
CreatedAt time.Time `json:"created_at"`
}
func (e Entry) Filename() string { return e.Namespace + "-" + e.Name + "-" + e.Version + ".tar.gz" }
// IndexPath is the entry's hidden index file.
func (e Entry) IndexPath() string { return IndexPrefix + e.Namespace + "/" + e.Name + "/" + e.Version }
// TaskID names the import task returned on publish.
func (e Entry) TaskID() string { return e.Namespace + "-" + e.Name + "-" + e.Version }
// ParseCollection reads collection_info from a collection tarball's top-level
// MANIFEST.json. Every member is checked, so a tarball whose paths or links
// escape the collection root is rejected even after the manifest.
func ParseCollection(r io.Reader) (*Entry, error) {
zr, err := gzip.NewReader(r)
if err != nil {
return nil, fmt.Errorf("not a gzip tarball: %w", err)
}
scan := &io.LimitedReader{R: zr, N: MaxScanBytes}
tr := tar.NewReader(scan)
var e *Entry
for {
hdr, err := tr.Next()
if scan.N <= 0 {
return nil, errors.New("collection exceeds decompressed size limit")
}
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("read tarball: %w", err)
}
name, err := memberPath(hdr)
if err != nil {
return nil, err
}
if name != "MANIFEST.json" || hdr.Typeflag != tar.TypeReg {
continue
}
if e != nil {
return nil, errors.New("duplicate MANIFEST.json")
}
if e, err = parseManifest(io.LimitReader(tr, MaxManifestBytes+1)); err != nil {
return nil, err
}
}
if e == nil {
return nil, errors.New("no MANIFEST.json in collection tarball")
}
return e, nil
}
// memberPath returns hdr's cleaned name, rejecting absolute or escaping
// paths and links, and anything but files, directories and symlinks.
func memberPath(hdr *tar.Header) (string, error) {
name := path.Clean(strings.TrimPrefix(hdr.Name, "./"))
if escapes(name) {
return "", fmt.Errorf("tarball member %q escapes the collection root", hdr.Name)
}
switch hdr.Typeflag {
case tar.TypeReg, tar.TypeDir:
case tar.TypeSymlink:
if path.IsAbs(hdr.Linkname) || escapes(path.Join(path.Dir(name), hdr.Linkname)) {
return "", fmt.Errorf("tarball symlink %q escapes the collection root", hdr.Name)
}
default:
return "", fmt.Errorf("tarball member %q has unsupported type %q", hdr.Name, hdr.Typeflag)
}
return name, nil
}
func escapes(p string) bool {
return path.IsAbs(p) || p == ".." || strings.HasPrefix(p, "../") || strings.Contains(p, "\\")
}
func parseManifest(r io.Reader) (*Entry, error) {
raw, err := io.ReadAll(r)
if err != nil {
return nil, fmt.Errorf("read MANIFEST.json: %w", err)
}
if int64(len(raw)) > MaxManifestBytes {
return nil, fmt.Errorf("MANIFEST.json exceeds %d bytes", MaxManifestBytes)
}
var doc struct {
CollectionInfo map[string]json.RawMessage `json:"collection_info"`
}
if err := json.Unmarshal(raw, &doc); err != nil || doc.CollectionInfo == nil {
return nil, errors.New("MANIFEST.json has no collection_info")
}
info := doc.CollectionInfo
e := &Entry{}
for k, dst := range map[string]*string{"namespace": &e.Namespace, "name": &e.Name, "version": &e.Version} {
if err := json.Unmarshal(info[k], dst); err != nil {
return nil, fmt.Errorf("collection_info.%s is not a string", k)
}
}
if !NameRe.MatchString(e.Namespace) || !NameRe.MatchString(e.Name) || !VersionRe.MatchString(e.Version) {
return nil, fmt.Errorf("invalid collection %q.%q version %q", e.Namespace, e.Name, e.Version)
}
deps := map[string]string{}
if d := info["dependencies"]; len(d) > 0 && string(d) != "null" {
if err := json.Unmarshal(d, &deps); err != nil {
return nil, errors.New("collection_info.dependencies must map collection names to version ranges")
}
}
for k := range deps {
ns, n, ok := strings.Cut(k, ".")
if !ok || !NameRe.MatchString(ns) || !NameRe.MatchString(n) {
return nil, fmt.Errorf("invalid dependency %q", k)
}
}
info["dependencies"], _ = json.Marshal(deps)
if e.Metadata, err = json.Marshal(info); err != nil {
return nil, err
}
return e, nil
}
// APIPath is a parsed Galaxy v3 request path below a repo.
type APIPath struct {
Kind APIKind
Namespace, Name, Version string
TaskID string
}
type APIKind int
const (
APIUnknown APIKind = iota
APIRoot
APICollection
APIVersions
APIVersion
APIImport
)
// ParseAPIPath parses api/, api/v3/collections/<ns>/<name>/[versions/[<v>/]]
// and api/v3/imports/collections/<id>/, with or without trailing slashes.
func ParseAPIPath(p string) APIPath {
p = strings.TrimSuffix(p, "/")
if p == "api" {
return APIPath{Kind: APIRoot}
}
if id, ok := strings.CutPrefix(p, "api/v3/imports/collections/"); ok && id != "" && !strings.Contains(id, "/") {
return APIPath{Kind: APIImport, TaskID: id}
}
rest, ok := strings.CutPrefix(p, "api/v3/collections/")
if !ok {
return APIPath{}
}
segs := strings.Split(rest, "/")
if len(segs) < 2 || !NameRe.MatchString(segs[0]) || !NameRe.MatchString(segs[1]) {
return APIPath{}
}
ap := APIPath{Namespace: segs[0], Name: segs[1]}
switch {
case len(segs) == 2:
ap.Kind = APICollection
case len(segs) == 3 && segs[2] == "versions":
ap.Kind = APIVersions
case len(segs) == 4 && segs[2] == "versions" && VersionRe.MatchString(segs[3]):
ap.Kind, ap.Version = APIVersion, segs[3]
default:
return APIPath{}
}
return ap
}
// Root is the API root ansible-galaxy discovers.
func Root() map[string]any {
return map[string]any{"available_versions": map[string]string{"v3": "v3/"}}
}
func CollectionPath(ns, name string) string { return "/api/v3/collections/" + ns + "/" + name + "/" }
func VersionPath(ns, name, version string) string {
return CollectionPath(ns, name) + "versions/" + url.PathEscape(version) + "/"
}
// VersionRef is one entry of a versions list.
type VersionRef struct {
Version string `json:"version"`
CreatedAt string `json:"created_at,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
}
func (e Entry) Ref() VersionRef {
t := e.CreatedAt.UTC().Format(time.RFC3339Nano)
return VersionRef{Version: e.Version, CreatedAt: t, UpdatedAt: t}
}
// CollectionDoc describes a collection from its versions; base is the repo
// URL the hrefs are built on.
func CollectionDoc(base, ns, name string, refs []VersionRef) map[string]any {
var created, updated time.Time
for _, r := range refs {
if t, err := time.Parse(time.RFC3339Nano, r.CreatedAt); err == nil && (created.IsZero() || t.Before(created)) {
created = t
}
if t, err := time.Parse(time.RFC3339Nano, r.UpdatedAt); err == nil && t.After(updated) {
updated = t
}
}
return map[string]any{
"href": base + CollectionPath(ns, name),
"namespace": ns,
"name": name,
"deprecated": false,
"versions_url": base + CollectionPath(ns, name) + "versions/",
"created_at": formatTime(created),
"updated_at": formatTime(updated),
}
}
func formatTime(t time.Time) string {
if t.IsZero() {
return ""
}
return t.UTC().Format(time.RFC3339Nano)
}
// Page reads limit/offset as the remote forwards them, defaulting to 100/0.
func Page(q url.Values) (limit, offset int) {
limit, offset = maxPageLimit, 0
if v, ok := pageLimit(q.Get("limit")); ok {
limit, _ = strconv.Atoi(v)
}
if v, ok := pageOffset(q.Get("offset")); ok {
offset, _ = strconv.Atoi(v)
}
return limit, offset
}
// VersionsPage is one page of refs in Galaxy v3 list form. Links are
// root-relative like Galaxy's, since ansible-galaxy splices them onto the
// server URL.
func VersionsPage(base, ns, name string, refs []VersionRef, limit, offset int) map[string]any {
list := rootPath(base) + CollectionPath(ns, name) + "versions/"
link := func(off int) any {
return list + "?" + url.Values{"limit": {strconv.Itoa(limit)}, "offset": {strconv.Itoa(off)}}.Encode()
}
offset = min(offset, len(refs))
var previous, next any
if offset > 0 {
previous = link(max(offset-limit, 0))
}
if offset+limit < len(refs) {
next = link(offset + limit)
}
data := []map[string]any{}
for _, r := range refs[offset:min(offset+limit, len(refs))] {
data = append(data, map[string]any{
"version": r.Version,
"href": base + VersionPath(ns, name, r.Version),
"created_at": r.CreatedAt,
"updated_at": r.UpdatedAt,
"marks": []string{},
})
}
return map[string]any{
"meta": map[string]any{"count": len(refs)},
"links": map[string]any{"first": link(0), "previous": previous, "next": next, "last": link(max(len(refs)-1, 0) / limit * limit)},
"data": data,
}
}
// rootPath drops the scheme and host from base.
func rootPath(base string) string {
if u, err := url.Parse(base); err == nil {
return u.EscapedPath()
}
return base
}
// VersionDoc is a version's detail; the tarball is served from the repo at
// artifacts/<filename>.
func VersionDoc(base string, e Entry) map[string]any {
t := e.CreatedAt.UTC().Format(time.RFC3339Nano)
return map[string]any{
"href": base + VersionPath(e.Namespace, e.Name, e.Version),
"namespace": map[string]string{"name": e.Namespace},
"collection": map[string]string{"name": e.Name},
"name": e.Name,
"version": e.Version,
"download_url": base + "/" + ArtifactPrefix + e.Filename(),
"artifact": map[string]any{"filename": e.Filename(), "sha256": e.SHA256, "size": e.Size},
"metadata": e.Metadata,
"signatures": []string{},
"created_at": t,
"updated_at": t,
}
}
// ImportTask is a finished import: publishing completes synchronously.
func ImportTask(e Entry) map[string]any {
t := e.CreatedAt.UTC().Format(time.RFC3339Nano)
return map[string]any{
"id": e.TaskID(), "state": "completed", "created_at": t, "started_at": t, "finished_at": t,
"messages": []any{}, "error": nil,
}
}
+256
View File
@@ -0,0 +1,256 @@
package ansible
import (
"archive/tar"
"bytes"
"compress/gzip"
"encoding/json"
"fmt"
"net/url"
"strings"
"testing"
)
type member struct {
hdr tar.Header
body string
}
func collectionTar(t *testing.T, members ...member) []byte {
t.Helper()
var buf bytes.Buffer
zw := gzip.NewWriter(&buf)
tw := tar.NewWriter(zw)
for _, m := range members {
h := m.hdr
if h.Typeflag == 0 {
h.Typeflag = tar.TypeReg
}
if h.Typeflag == tar.TypeReg {
h.Size = int64(len(m.body))
}
h.Mode = 0o644
if err := tw.WriteHeader(&h); err != nil {
t.Fatal(err)
}
_, _ = tw.Write([]byte(m.body))
}
_ = tw.Close()
_ = zw.Close()
return buf.Bytes()
}
func manifest(ns, name, version, deps string) member {
return member{tar.Header{Name: "MANIFEST.json"}, `{"collection_info":{"namespace":"` + ns + `","name":"` + name +
`","version":"` + version + `","authors":["a"],"dependencies":` + deps + `}}`}
}
func TestParseCollection(t *testing.T) {
e, err := ParseCollection(bytes.NewReader(collectionTar(t,
member{tar.Header{Name: "plugins/", Typeflag: tar.TypeDir}, ""},
member{tar.Header{Name: "plugins/modules/hello.py"}, "x"},
member{tar.Header{Name: "docs/link", Typeflag: tar.TypeSymlink, Linkname: "../plugins/modules/hello.py"}, ""},
manifest("unkin", "e2e_hello", "1.0.0-rc.1+b1", `{"ansible.posix":">=1.0.0"}`),
)))
if err != nil {
t.Fatal(err)
}
if e.Namespace != "unkin" || e.Name != "e2e_hello" || e.Version != "1.0.0-rc.1+b1" || e.Filename() != "unkin-e2e_hello-1.0.0-rc.1+b1.tar.gz" {
t.Fatalf("entry = %+v", e)
}
var md struct {
Authors []string `json:"authors"`
Dependencies map[string]string `json:"dependencies"`
}
if err := json.Unmarshal(e.Metadata, &md); err != nil || md.Dependencies["ansible.posix"] != ">=1.0.0" || len(md.Authors) != 1 {
t.Fatalf("metadata = %s (%v)", e.Metadata, err)
}
e, err = ParseCollection(bytes.NewReader(collectionTar(t, manifest("a", "b", "1.0.0", "null"))))
if err != nil || !strings.Contains(string(e.Metadata), `"dependencies":{}`) {
t.Fatalf("null dependencies = %s (%v), want {}", e.Metadata, err)
}
}
func TestParseCollectionRejects(t *testing.T) {
ok := manifest("a", "b", "1.0.0", "{}")
tests := []struct {
name string
members []member
want string
}{
{"no manifest", []member{{tar.Header{Name: "README.md"}, "x"}}, "no MANIFEST.json"},
{"nested manifest only", []member{{tar.Header{Name: "x/MANIFEST.json"}, ok.body}}, "no MANIFEST.json"},
{"dotdot", []member{ok, {tar.Header{Name: "../evil"}, "x"}}, "escapes"},
{"dotdot after clean", []member{ok, {tar.Header{Name: "a/../../evil"}, "x"}}, "escapes"},
{"absolute", []member{ok, {tar.Header{Name: "/etc/passwd"}, "x"}}, "escapes"},
{"symlink escape", []member{ok, {tar.Header{Name: "a/l", Typeflag: tar.TypeSymlink, Linkname: "../../x"}, ""}}, "symlink"},
{"absolute symlink", []member{ok, {tar.Header{Name: "l", Typeflag: tar.TypeSymlink, Linkname: "/etc"}, ""}}, "symlink"},
{"hardlink", []member{ok, {tar.Header{Name: "l", Typeflag: tar.TypeLink, Linkname: "MANIFEST.json"}, ""}}, "unsupported type"},
{"duplicate manifest", []member{ok, ok}, "duplicate"},
{"bad namespace", []member{manifest("Bad", "b", "1.0.0", "{}")}, "invalid collection"},
{"bad version", []member{manifest("a", "b", "1.0", "{}")}, "invalid collection"},
{"bad dependency", []member{manifest("a", "b", "1.0.0", `{"nodot":"*"}`)}, "invalid dependency"},
{"list dependencies", []member{manifest("a", "b", "1.0.0", `["x.y"]`)}, "dependencies must map"},
{"huge manifest", []member{{tar.Header{Name: "MANIFEST.json"}, strings.Repeat(" ", int(MaxManifestBytes)+1)}}, "exceeds"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := ParseCollection(bytes.NewReader(collectionTar(t, tt.members...)))
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Fatalf("err = %v, want %q", err, tt.want)
}
})
}
if _, err := ParseCollection(strings.NewReader("not gzip")); err == nil {
t.Error("non-gzip input accepted")
}
}
func TestParseCollectionScanLimit(t *testing.T) {
old := MaxScanBytes
MaxScanBytes = 4096
defer func() { MaxScanBytes = old }()
_, err := ParseCollection(bytes.NewReader(collectionTar(t,
member{tar.Header{Name: "big"}, strings.Repeat("x", 8192)},
manifest("a", "b", "1.0.0", "{}"),
)))
if err == nil || !strings.Contains(err.Error(), "decompressed size limit") {
t.Fatalf("err = %v, want decompressed size limit", err)
}
}
func TestParseAPIPath(t *testing.T) {
tests := []struct {
in string
want APIPath
}{
{"api", APIPath{Kind: APIRoot}},
{"api/", APIPath{Kind: APIRoot}},
{"api/v3/collections/unkin/e2e_hello/", APIPath{Kind: APICollection, Namespace: "unkin", Name: "e2e_hello"}},
{"api/v3/collections/unkin/e2e_hello/versions", APIPath{Kind: APIVersions, Namespace: "unkin", Name: "e2e_hello"}},
{"api/v3/collections/unkin/e2e_hello/versions/1.0.0/", APIPath{Kind: APIVersion, Namespace: "unkin", Name: "e2e_hello", Version: "1.0.0"}},
{"api/v3/imports/collections/unkin-e2e_hello-1.0.0/", APIPath{Kind: APIImport, TaskID: "unkin-e2e_hello-1.0.0"}},
{"api/v3/collections/unkin/e2e_hello/versions/1.0/", APIPath{}},
{"api/v3/collections/../x/", APIPath{}},
{"api/v3/collections/unkin/e2e_hello/versions/1.0.0/extra/", APIPath{}},
{"artifacts/unkin-e2e_hello-1.0.0.tar.gz", APIPath{}},
{"api/v3/imports/collections/a/b/", APIPath{}},
}
for _, tt := range tests {
if got := ParseAPIPath(tt.in); got != tt.want {
t.Errorf("ParseAPIPath(%q) = %+v, want %+v", tt.in, got, tt.want)
}
}
}
func TestVersionsPage(t *testing.T) {
refs := []VersionRef{{Version: "3.0.0"}, {Version: "2.0.0"}, {Version: "1.0.0"}}
list := "/r/api/v3/collections/a/b/versions/"
links := func(p map[string]any) map[string]any { return p["links"].(map[string]any) }
versions := func(p map[string]any) (out []string) {
for _, d := range p["data"].([]map[string]any) {
out = append(out, d["version"].(string))
}
return out
}
p := VersionsPage("https://h/r", "a", "b", refs, 2, 0)
if got := versions(p); strings.Join(got, ",") != "3.0.0,2.0.0" {
t.Fatalf("page 1 = %v", got)
}
if l := links(p); l["previous"] != nil || l["next"] != list+"?limit=2&offset=2" || l["last"] != list+"?limit=2&offset=2" {
t.Fatalf("page 1 links = %v", l)
}
if p["meta"].(map[string]any)["count"] != 3 {
t.Fatalf("count = %v", p["meta"])
}
p = VersionsPage("https://h/r", "a", "b", refs, 2, 2)
if got := versions(p); strings.Join(got, ",") != "1.0.0" {
t.Fatalf("page 2 = %v", got)
}
if l := links(p); l["next"] != nil || l["previous"] != list+"?limit=2&offset=0" {
t.Fatalf("page 2 links = %v", l)
}
if got := versions(VersionsPage("", "a", "b", refs, 2, 99)); len(got) != 0 {
t.Fatalf("offset past end = %v, want empty", got)
}
}
// TestVersionsPageFollow pages through more than 100 versions the way
// ansible-galaxy does: old clients splice next onto the versions URL in place
// of its path, new ones urljoin it onto the server.
func TestVersionsPageFollow(t *testing.T) {
var refs []VersionRef
for i := 104; i >= 0; i-- {
refs = append(refs, VersionRef{Version: fmt.Sprintf("1.0.%d", i)})
}
server := "https://h:8443/api/v1/local/r%2Bx/"
base := strings.TrimSuffix(server, "/")
versionsURL := server + "api/v3/collections/a/b/versions/"
for _, follow := range map[string]func(string) string{
"replace": func(link string) string {
u, _ := url.Parse(versionsURL)
return strings.Replace(versionsURL, u.EscapedPath(), link, 1)
},
"urljoin": func(link string) string {
s, _ := url.Parse(server)
l, _ := url.Parse(link)
return s.ResolveReference(l).String()
},
} {
var got []string
next := versionsURL + "?limit=100"
for pages := 0; next != ""; pages++ {
if pages > 3 {
t.Fatal("pagination does not terminate")
}
u, err := url.Parse(next)
if err != nil || u.Host != "h:8443" || !strings.HasPrefix(u.EscapedPath(), "/api/v1/local/r%2Bx/api/v3/") {
t.Fatalf("followed link %q is not on the server", next)
}
limit, offset := Page(u.Query())
p := VersionsPage(base, "a", "b", refs, limit, offset)
for _, d := range p["data"].([]map[string]any) {
got = append(got, d["version"].(string))
}
next = ""
if l, ok := p["links"].(map[string]any)["next"].(string); ok {
next = follow(l)
}
}
if len(got) != 105 || got[0] != "1.0.104" || got[104] != "1.0.0" {
t.Fatalf("collected %d versions, first %v", len(got), got[:1])
}
}
}
func TestCollectionDocTimes(t *testing.T) {
d := CollectionDoc("", "a", "b", []VersionRef{
{CreatedAt: "2026-01-01T00:00:05Z", UpdatedAt: "2026-01-01T00:00:05Z"},
{CreatedAt: "2026-01-01T00:00:05.5Z", UpdatedAt: "2026-01-01T00:00:05.5Z"},
{CreatedAt: "2026-01-01T00:00:04.9Z", UpdatedAt: "2026-01-01T00:00:04.9Z"},
})
if d["created_at"] != "2026-01-01T00:00:04.9Z" || d["updated_at"] != "2026-01-01T00:00:05.5Z" {
t.Fatalf("created/updated = %v/%v", d["created_at"], d["updated_at"])
}
}
func TestPage(t *testing.T) {
for _, tt := range []struct {
q string
limit, offset int
}{
{"", 100, 0},
{"limit=5&offset=10", 5, 10},
{"limit=5000", 100, 0},
{"limit=0&offset=-1", 100, 0},
} {
q, _ := url.ParseQuery(tt.q)
if l, o := Page(q); l != tt.limit || o != tt.offset {
t.Errorf("Page(%q) = %d,%d want %d,%d", tt.q, l, o, tt.limit, tt.offset)
}
}
}
@@ -0,0 +1,6 @@
package provider
// ContentValidator checks an upload's bytes (spooled to localFile) before it is stored.
type ContentValidator interface {
ValidateContent(storagePath, localFile string) error
}
+12
View File
@@ -0,0 +1,12 @@
package provider
// QueryForwarder is a remote provider whose upstream answers depend on the
// query string (e.g. paginated JSON APIs). Only the named params for path are
// forwarded upstream and kept in the cache key, each as the canonical value
// its validator returns; a value the validator rejects is dropped.
type QueryForwarder interface {
ForwardedQuery(path string) map[string]QueryValidator
}
// QueryValidator canonicalises a raw query value, reporting false to drop it.
type QueryValidator func(v string) (string, bool)
+15 -14
View File
@@ -333,6 +333,9 @@ func (p *GitHubProvider) scanWithState(ctx context.Context, remote models.Remote
return newEtag, false, err
}
// A transiently failed asset fails the scan so the ETag is not advanced and
// the retry re-derives it; an invalid package is skipped as no retry fixes it.
var failed []error
seen := map[string]bool{}
for _, rel := range releases {
if rel.Draft {
@@ -361,10 +364,14 @@ func (p *GitHubProvider) scanWithState(ctx context.Context, remote models.Remote
meta, err := p.deriveAsset(ctx, remote, asset, fp)
if err != nil {
slog.Warn("github_deb: derive asset failed", "remote", remote.Name, "asset", asset.Name, "error", err)
if !errors.Is(err, provider.ErrInvalidPackage) {
failed = append(failed, fmt.Errorf("%s: %w", asset.Name, err))
}
continue
}
if err := store.InsertDebMetadata(ctx, meta); err != nil {
slog.Error("github_deb: insert metadata failed", "remote", remote.Name, "asset", asset.Name, "error", err)
failed = append(failed, fmt.Errorf("%s: %w", asset.Name, err))
continue
}
slog.Info("github_deb: derived asset", "remote", remote.Name, "name", meta.Name, "version", meta.Version, "arch", meta.Architecture)
@@ -376,7 +383,7 @@ func (p *GitHubProvider) scanWithState(ctx context.Context, remote models.Remote
_ = store.DeleteDebMetadata(ctx, remote.Name, fp)
}
}
return newEtag, true, nil
return newEtag, true, errors.Join(failed...)
}
type ghRelease struct {
@@ -468,7 +475,7 @@ func (p *GitHubProvider) deriveAsset(ctx context.Context, remote models.Remote,
Size: asset.Size,
}
if meta.Name == "" {
return nil, errors.New("control missing Package field")
return nil, fmt.Errorf("%w: control missing Package field", provider.ErrInvalidPackage)
}
// The Packages SHA256 must be the sha256 of the whole .deb. Prefer GitHub's
@@ -503,13 +510,13 @@ func (p *GitHubProvider) fetchControl(ctx context.Context, remote models.Remote,
}
control, complete, perr := controlFromPrefix(body)
if perr != nil {
return "", fmt.Errorf("parse deb control: %w", perr)
return "", fmt.Errorf("%w: parse deb control: %w", provider.ErrInvalidPackage, perr)
}
if complete {
return control, nil
}
if full || n >= p.headerMax {
return "", fmt.Errorf("control.tar not found within %d bytes of %s", n, downloadURL)
return "", fmt.Errorf("%w: control.tar not found within %d bytes of %s", provider.ErrInvalidPackage, n, downloadURL)
}
n *= 2
if n > p.headerMax {
@@ -567,7 +574,7 @@ func controlFromPrefix(prefix []byte) (control string, complete bool, err error)
}
// rangeGet returns the first n bytes of downloadURL. full is true when the
// response body was shorter than n (i.e. we already have the whole object).
// response holds the whole object.
func (p *GitHubProvider) rangeGet(ctx context.Context, remote models.Remote, downloadURL string, n int64) ([]byte, bool, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
if err != nil {
@@ -589,15 +596,9 @@ func (p *GitHubProvider) rangeGet(ctx context.Context, remote models.Remote, dow
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {
return nil, false, fmt.Errorf("range GET %s: status %d", downloadURL, resp.StatusCode)
return nil, false, fmt.Errorf("range GET: %w", provider.AssetStatusError(downloadURL, resp))
}
body, err := io.ReadAll(io.LimitReader(resp.Body, n))
if err != nil {
return nil, false, err
}
full := int64(len(body)) < n
return body, full, nil
return provider.ReadPrefix(resp, n)
}
func (p *GitHubProvider) computeSHA256(ctx context.Context, remote models.Remote, downloadURL string) (string, error) {
@@ -620,7 +621,7 @@ func (p *GitHubProvider) computeSHA256(ctx context.Context, remote models.Remote
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("GET %s: status %d", downloadURL, resp.StatusCode)
return "", fmt.Errorf("GET: %w", provider.AssetStatusError(downloadURL, resp))
}
h := sha256.New()
+22 -3
View File
@@ -7,6 +7,7 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -81,15 +82,17 @@ type githubFixture struct {
notModHit int
releaseAuth string
assetAuth string
assetFail map[string]int // asset filename -> downloads left to answer 500
mu sync.Mutex
}
func newGitHubFixture(t *testing.T, withDigest bool) *githubFixture {
t.Helper()
f := &githubFixture{
debBytes: map[string][]byte{},
rangeHit: map[string]int{},
fullHit: map[string]int{},
debBytes: map[string][]byte{},
rangeHit: map[string]int{},
fullHit: map[string]int{},
assetFail: map[string]int{},
}
f.debBytes["demo_1.2-3_amd64.deb"] = testsupport.MinimalDeb("demo", "1.2-3", "amd64")
@@ -139,6 +142,12 @@ func newGitHubFixture(t *testing.T, withDigest bool) *githubFixture {
}
rng := r.Header.Get("Range")
f.mu.Lock()
if f.assetFail[name] > 0 {
f.assetFail[name]--
f.mu.Unlock()
http.Error(w, "boom", http.StatusInternalServerError)
return
}
f.assetAuth = r.Header.Get("Authorization")
if rng != "" {
f.rangeHit[name]++
@@ -460,3 +469,13 @@ func TestGitHubAssetPatternFilter(t *testing.T) {
t.Fatalf("pattern filter failed, rows=%+v", rows)
}
}
func TestGitHubDeriveAssetMissingPackageIsInvalid(t *testing.T) {
fx := newGitHubFixture(t, true)
fx.debBytes["nameless_1_amd64.deb"] = testsupport.MinimalDeb("", "1", "amd64")
asset := ghAsset{BrowserDownloadURL: fx.srv.URL + "/acme/tools/releases/download/v1.2-3/nameless_1_amd64.deb", Digest: "sha256:00"}
_, err := newTestProvider().deriveAsset(context.Background(), fx.remote(), asset, "fp")
if !errors.Is(err, provider.ErrInvalidPackage) {
t.Fatalf("want ErrInvalidPackage, got %v", err)
}
}
+55
View File
@@ -308,3 +308,58 @@ func TestSyncerPrimeBypassesRecencyPeriodicDoesNot(t *testing.T) {
t.Fatalf("periodic scan ran inside recency window: %d -> %d releases calls", releasesAfterPrime, fx.releasesHit)
}
}
// A transiently failed asset fails the scan: the ETag is not advanced, a retry
// is scheduled, and the retry against the unchanged release list derives it. An
// invalid package is skipped instead, since no retry can fix it.
func TestSyncerFailedAssetRetry(t *testing.T) {
for _, tc := range []struct {
name string
corrupt bool
wantRetry bool
}{
{name: "transient", wantRetry: true},
{name: "invalid package", corrupt: true},
} {
t.Run(tc.name, func(t *testing.T) {
fx := newGitHubFixture(t, true)
fx.etag = `"v1"`
if tc.corrupt {
fx.debBytes["other_9-9_arm64.deb"] = []byte("not a package")
} else {
fx.debBytes["other_9-9_arm64.deb"] = testsupport.MinimalDeb("other", "9-9", "arm64")
fx.assetFail["other_9-9_arm64.deb"] = 1
}
store := newFakeSyncStore()
s := newSyncer(store, newTestProvider(), testSyncConfig())
remote := fx.remote()
bg := context.Background()
s.process(bg, syncJob{remote: remote, prime: true})
_, pending := store.retryAt[remote.Name]
if pending != tc.wantRetry {
t.Fatalf("retry pending=%v, want %v", pending, tc.wantRetry)
}
if _, saved := store.etags[remote.Name]; saved == tc.wantRetry {
t.Fatalf("etag saved=%v after first scan", saved)
}
if rows, _ := store.ListDebMetadataEntries(bg, remote.Name); len(rows) != 1 {
t.Fatalf("healthy asset not served: %d rows", len(rows))
}
if !tc.wantRetry {
return
}
store.mu.Lock()
store.retryAt[remote.Name] = time.Now().Add(-time.Second)
store.mu.Unlock()
s.process(bg, syncJob{remote: remote})
if _, pending := store.retryAt[remote.Name]; pending || store.etags[remote.Name] != `"v1"` {
t.Fatalf("retry not recorded: pending=%v etag=%q", pending, store.etags[remote.Name])
}
if rows, _ := store.ListDebMetadataEntries(bg, remote.Name); len(rows) != 2 {
t.Fatalf("retry did not derive the failed asset: %d rows", len(rows))
}
})
}
}
+223
View File
@@ -0,0 +1,223 @@
package goproxy
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"slices"
"strings"
"golang.org/x/mod/modfile"
"golang.org/x/mod/module"
"golang.org/x/mod/semver"
modzip "golang.org/x/mod/zip"
"git.unkin.net/unkin/artifactapi/internal/provider"
)
type versionFile struct {
module string
version string
ext string
}
// parseVersionFile splits "<escaped module>/@v/<version>.<ext>" into its parts.
func parseVersionFile(path string) (versionFile, error) {
escMod, file, ok := strings.Cut(strings.TrimPrefix(path, "/"), "/@v/")
if !ok || escMod == "" || strings.Contains(file, "/") {
return versionFile{}, fmt.Errorf("path %q must be <module>/@v/<version>.{zip,mod,info}", path)
}
dot := strings.LastIndex(file, ".")
if dot <= 0 {
return versionFile{}, fmt.Errorf("path %q must end in .zip, .mod or .info", path)
}
ext := file[dot+1:]
if ext != "zip" && ext != "mod" && ext != "info" {
return versionFile{}, fmt.Errorf("path %q must end in .zip, .mod or .info", path)
}
mod, err := module.UnescapePath(escMod)
if err != nil {
return versionFile{}, fmt.Errorf("module path must be case-encoded (uppercase as !lower): %w", err)
}
ver, err := module.UnescapeVersion(file[:dot])
if err != nil {
return versionFile{}, err
}
if err := module.Check(mod, ver); err != nil {
return versionFile{}, err
}
if semver.Canonical(ver) != ver {
return versionFile{}, fmt.Errorf("version %q is not canonical semver", ver)
}
return versionFile{module: mod, version: ver, ext: ext}, nil
}
func (v versionFile) storagePath() string {
escMod, _ := module.EscapePath(v.module)
escVer, _ := module.EscapeVersion(v.version)
return escMod + "/@v/" + escVer + "." + v.ext
}
func (p *Provider) ValidateUpload(filePath string) (storagePath, contentType string, err error) {
vf, err := parseVersionFile(filePath)
if err != nil {
return "", "", err
}
sp := vf.storagePath()
return sp, p.ContentType(sp), nil
}
func (p *Provider) ValidateContent(storagePath, localFile string) error {
vf, err := parseVersionFile(storagePath)
if err != nil {
return err
}
switch vf.ext {
case "zip":
if _, err := modzip.CheckZip(module.Version{Path: vf.module, Version: vf.version}, localFile); err != nil {
return fmt.Errorf("invalid module zip (files must sit under %s@%s/): %w", vf.module, vf.version, err)
}
case "mod":
data, err := os.ReadFile(localFile)
if err != nil {
return err
}
mf, err := modfile.ParseLax("go.mod", data, nil)
if err != nil {
return fmt.Errorf("invalid go.mod: %w", err)
}
if mf.Module == nil || mf.Module.Mod.Path != vf.module {
return fmt.Errorf("go.mod must declare module %s", vf.module)
}
case "info":
data, err := os.ReadFile(localFile)
if err != nil {
return err
}
var info struct{ Version string }
if err := json.Unmarshal(data, &info); err != nil {
return fmt.Errorf("invalid .info JSON: %w", err)
}
if info.Version != vf.version {
return fmt.Errorf(".info Version %q does not match %s", info.Version, vf.version)
}
}
return nil
}
func (p *Provider) UploadResponse(storagePath, contentHash string, sizeBytes int64) map[string]any {
vf, _ := parseVersionFile(storagePath)
return map[string]any{
"module": vf.module,
"version": vf.version,
"path": storagePath,
"content_hash": contentHash,
"size_bytes": sizeBytes,
}
}
// versions returns the canonical versions of escMod that have a .mod uploaded, sorted ascending.
func versions(ctx context.Context, files provider.FileStore, repoName, escMod string) (versions []string, infos map[string]bool, err error) {
entries, err := files.ListFilesByPrefix(ctx, repoName, escMod+"/@v/")
if err != nil {
return nil, nil, err
}
infos = map[string]bool{}
for _, e := range entries {
vf, err := parseVersionFile(e.FilePath)
if err != nil || vf.storagePath() != e.FilePath {
continue
}
switch vf.ext {
case "mod":
versions = append(versions, vf.version)
case "info":
infos[vf.version] = true
}
}
semver.Sort(versions)
return versions, infos, nil
}
// Latest is the highest release in sorted vs, else the highest prerelease.
func Latest(vs []string) string {
for i := len(vs) - 1; i >= 0; i-- {
if semver.Prerelease(vs[i]) == "" {
return vs[i]
}
}
if len(vs) == 0 {
return ""
}
return vs[len(vs)-1]
}
func infoJSON(version string) []byte {
b, _ := json.Marshal(map[string]string{"Version": version})
return b
}
func (p *Provider) ServeLocalIndex(w http.ResponseWriter, r *http.Request, files provider.FileStore, repoName, path string) bool {
path = strings.TrimPrefix(path, "/")
switch {
case strings.HasSuffix(path, "/@v/list"), strings.HasSuffix(path, "/@latest"):
case strings.HasSuffix(path, ".info"):
vf, err := parseVersionFile(path)
if err != nil {
return false
}
escMod, _ := module.EscapePath(vf.module)
vs, infos, err := versions(r.Context(), files, repoName, escMod)
if err != nil || infos[vf.version] || !slices.Contains(vs, vf.version) {
return false
}
default:
return false
}
body, err := p.GenerateLocalIndex(r.Context(), files, repoName, path)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return true
}
w.Header().Set("Content-Type", p.ContentType(path))
w.WriteHeader(http.StatusOK)
_, _ = w.Write(body)
return true
}
func (p *Provider) GenerateLocalIndex(ctx context.Context, files provider.FileStore, repoName, path string) ([]byte, error) {
path = strings.TrimPrefix(path, "/")
if escMod, ok := strings.CutSuffix(path, "/@v/list"); ok {
vs, _, err := versions(ctx, files, repoName, escMod)
if err != nil {
return nil, err
}
var b strings.Builder
for _, v := range vs {
b.WriteString(v + "\n")
}
return []byte(b.String()), nil
}
if escMod, ok := strings.CutSuffix(path, "/@latest"); ok {
vs, _, err := versions(ctx, files, repoName, escMod)
if err != nil {
return nil, err
}
v := Latest(vs)
if v == "" {
return nil, fmt.Errorf("no versions of %s", escMod)
}
return infoJSON(v), nil
}
if strings.HasSuffix(path, ".info") {
vf, err := parseVersionFile(path)
if err != nil {
return nil, err
}
return infoJSON(vf.version), nil
}
return nil, fmt.Errorf("unsupported index path: %q", path)
}
+198
View File
@@ -0,0 +1,198 @@
package goproxy
import (
"archive/zip"
"context"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"golang.org/x/mod/module"
modzip "golang.org/x/mod/zip"
"git.unkin.net/unkin/artifactapi/internal/provider"
)
type fakeFileStore struct{ paths []string }
func (f *fakeFileStore) ListPackages(context.Context, string) ([]string, error) { return nil, nil }
func (f *fakeFileStore) ListFilesByPrefix(_ context.Context, _, prefix string) ([]provider.FileEntry, error) {
var out []provider.FileEntry
for _, p := range f.paths {
if strings.HasPrefix(p, prefix) {
out = append(out, provider.FileEntry{FilePath: p, ContentHash: "sha256:x"})
}
}
return out, nil
}
func TestValidateUpload(t *testing.T) {
p := &Provider{}
cases := []struct {
in, want string
ok bool
}{
{"example.unkin.net/hello/@v/v0.1.0.zip", "example.unkin.net/hello/@v/v0.1.0.zip", true},
{"/example.unkin.net/hello/@v/v0.1.0.mod", "example.unkin.net/hello/@v/v0.1.0.mod", true},
{"github.com/!burnt!sushi/toml/@v/v1.2.0.info", "github.com/!burnt!sushi/toml/@v/v1.2.0.info", true},
{"example.unkin.net/hello/v2/@v/v2.0.0-rc.1.zip", "example.unkin.net/hello/v2/@v/v2.0.0-rc.1.zip", true},
{"github.com/BurntSushi/toml/@v/v1.2.0.zip", "", false},
{"example.unkin.net/hello/@v/v0.1.zip", "", false},
{"example.unkin.net/hello/@v/v2.0.0.zip", "", false},
{"example.unkin.net/hello/@v/v0.1.0.tar.gz", "", false},
{"example.unkin.net/hello/v0.1.0.zip", "", false},
{"example.unkin.net/hello/@v/list", "", false},
}
for _, c := range cases {
got, ct, err := p.ValidateUpload(c.in)
if (err == nil) != c.ok {
t.Errorf("%s: err=%v, want ok=%v", c.in, err, c.ok)
continue
}
if c.ok && (got != c.want || ct == "") {
t.Errorf("%s: got %q %q, want %q", c.in, got, ct, c.want)
}
}
}
func writeZip(t *testing.T, files map[string]string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "m.zip")
f, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
zw := zip.NewWriter(f)
for name, body := range files {
w, err := zw.Create(name)
if err != nil {
t.Fatal(err)
}
if _, err := w.Write([]byte(body)); err != nil {
t.Fatal(err)
}
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
if err := f.Close(); err != nil {
t.Fatal(err)
}
return path
}
func writeFile(t *testing.T, body string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "f")
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
return path
}
func TestValidateContentZip(t *testing.T) {
p := &Provider{}
sp := "example.unkin.net/hello/@v/v0.1.0.zip"
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module example.unkin.net/hello\n"), 0o600)
os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\n"), 0o600)
good := filepath.Join(t.TempDir(), "good.zip")
f, _ := os.Create(good)
if err := modzip.CreateFromDir(f, module.Version{Path: "example.unkin.net/hello", Version: "v0.1.0"}, dir); err != nil {
t.Fatal(err)
}
f.Close()
if err := p.ValidateContent(sp, good); err != nil {
t.Errorf("valid zip rejected: %v", err)
}
bad := map[string]map[string]string{
"no prefix": {"go.mod": "module example.unkin.net/hello\n"},
"wrong version": {"example.unkin.net/hello@v0.2.0/go.mod": "module example.unkin.net/hello\n"},
"wrong module": {"example.unkin.net/other@v0.1.0/go.mod": "module example.unkin.net/other\n"},
}
for name, files := range bad {
if err := p.ValidateContent(sp, writeZip(t, files)); err == nil {
t.Errorf("%s: expected rejection", name)
}
}
if err := p.ValidateContent(sp, writeFile(t, "not a zip")); err == nil {
t.Error("non-zip accepted")
}
}
func TestValidateContentModInfo(t *testing.T) {
p := &Provider{}
mod := "example.unkin.net/hello/@v/v0.1.0.mod"
info := "example.unkin.net/hello/@v/v0.1.0.info"
cases := []struct {
path, body string
ok bool
}{
{mod, "module example.unkin.net/hello\n\ngo 1.25\n", true},
{mod, "module example.unkin.net/other\n", false},
{mod, "go 1.25\n", false},
{mod, "module (\n", false},
{info, `{"Version":"v0.1.0","Time":"2026-01-01T00:00:00Z"}`, true},
{info, `{"Version":"v0.2.0"}`, false},
{info, `nope`, false},
}
for _, c := range cases {
if err := p.ValidateContent(c.path, writeFile(t, c.body)); (err == nil) != c.ok {
t.Errorf("%s %q: err=%v, want ok=%v", c.path, c.body, err, c.ok)
}
}
}
func serve(t *testing.T, fs provider.FileStore, path string) (bool, int, string) {
t.Helper()
rec := httptest.NewRecorder()
handled := (&Provider{}).ServeLocalIndex(rec, httptest.NewRequest("GET", "/"+path, nil), fs, "go-local", path)
return handled, rec.Code, rec.Body.String()
}
func TestServeLocalIndex(t *testing.T) {
fs := &fakeFileStore{paths: []string{
"example.unkin.net/hello/@v/v0.10.0.mod",
"example.unkin.net/hello/@v/v0.10.0.zip",
"example.unkin.net/hello/@v/v0.2.0.mod",
"example.unkin.net/hello/@v/v0.2.0.zip",
"example.unkin.net/hello/@v/v0.2.0.info",
"example.unkin.net/hello/@v/v0.11.0-rc.1.mod",
"example.unkin.net/hello/@v/v0.3.0.zip",
"example.unkin.net/hello/v2/@v/v2.0.0.mod",
"example.unkin.net/!pre/@v/v1.0.0-beta.mod",
}}
if h, code, body := serve(t, fs, "example.unkin.net/hello/@v/list"); !h || code != 200 || body != "v0.2.0\nv0.10.0\nv0.11.0-rc.1\n" {
t.Errorf("list: %v %d %q", h, code, body)
}
if h, code, body := serve(t, fs, "example.unkin.net/hello/@latest"); !h || code != 200 || body != `{"Version":"v0.10.0"}` {
t.Errorf("latest: %v %d %q", h, code, body)
}
if h, _, body := serve(t, fs, "example.unkin.net/!pre/@latest"); !h || body != `{"Version":"v1.0.0-beta"}` {
t.Errorf("prerelease-only latest: %q", body)
}
if h, code, _ := serve(t, fs, "example.unkin.net/missing/@latest"); !h || code != 404 {
t.Errorf("missing latest: %v %d", h, code)
}
if h, code, body := serve(t, fs, "example.unkin.net/missing/@v/list"); !h || code != 200 || body != "" {
t.Errorf("missing list: %v %d %q", h, code, body)
}
if h, _, body := serve(t, fs, "example.unkin.net/hello/@v/v0.10.0.info"); !h || body != `{"Version":"v0.10.0"}` {
t.Errorf("synthesized info: %v %q", h, body)
}
if h, _, _ := serve(t, fs, "example.unkin.net/hello/@v/v0.2.0.info"); h {
t.Error("uploaded .info must be served from storage")
}
if h, _, _ := serve(t, fs, "example.unkin.net/hello/@v/v0.3.0.info"); h {
t.Error(".info without a .mod must not be synthesized")
}
if h, _, _ := serve(t, fs, "example.unkin.net/hello/@v/v0.10.0.zip"); h {
t.Error(".zip must be served from storage")
}
}
+3
View File
@@ -8,6 +8,9 @@ import (
"git.unkin.net/unkin/artifactapi/pkg/models"
)
// RemoteMountPath is the URL path remote proxies are served under.
const RemoteMountPath = "/api/v1/remote/"
type Mutability int
const (
+13 -12
View File
@@ -343,6 +343,9 @@ func (p *GitHubProvider) scanWithState(ctx context.Context, remote models.Remote
return newEtag, false, err
}
// A transiently failed asset fails the scan so the ETag is not advanced and
// the retry re-derives it; an invalid package is skipped as no retry fixes it.
var failed []error
seen := map[string]bool{}
for _, rel := range releases {
if rel.Draft {
@@ -373,10 +376,14 @@ func (p *GitHubProvider) scanWithState(ctx context.Context, remote models.Remote
meta, err := p.deriveAsset(ctx, remote, asset, fp)
if err != nil {
slog.Warn("github_rpm: derive asset failed", "remote", remote.Name, "asset", asset.Name, "error", err)
if !errors.Is(err, provider.ErrInvalidPackage) {
failed = append(failed, fmt.Errorf("%s: %w", asset.Name, err))
}
continue
}
if err := store.InsertRPMMetadata(ctx, meta); err != nil {
slog.Error("github_rpm: insert metadata failed", "remote", remote.Name, "asset", asset.Name, "error", err)
failed = append(failed, fmt.Errorf("%s: %w", asset.Name, err))
continue
}
slog.Info("github_rpm: derived asset", "remote", remote.Name, "name", meta.Name, "version", meta.Version, "arch", meta.Arch)
@@ -388,7 +395,7 @@ func (p *GitHubProvider) scanWithState(ctx context.Context, remote models.Remote
_ = store.DeleteRPMMetadata(ctx, remote.Name, fp)
}
}
return newEtag, true, nil
return newEtag, true, errors.Join(failed...)
}
type ghRelease struct {
@@ -569,12 +576,12 @@ func (p *GitHubProvider) fetchHeader(ctx context.Context, remote models.Remote,
}
continue
}
return nil, fmt.Errorf("parse rpm header: %w", perr)
return nil, fmt.Errorf("%w: parse rpm header: %w", provider.ErrInvalidPackage, perr)
}
}
// rangeGet returns the first n bytes of downloadURL. full is true when the
// response body was shorter than n (i.e. we already have the whole object).
// response holds the whole object.
func (p *GitHubProvider) rangeGet(ctx context.Context, remote models.Remote, downloadURL string, n int64) ([]byte, bool, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
if err != nil {
@@ -596,15 +603,9 @@ func (p *GitHubProvider) rangeGet(ctx context.Context, remote models.Remote, dow
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {
return nil, false, fmt.Errorf("range GET %s: status %d", downloadURL, resp.StatusCode)
return nil, false, fmt.Errorf("range GET: %w", provider.AssetStatusError(downloadURL, resp))
}
body, err := io.ReadAll(io.LimitReader(resp.Body, n))
if err != nil {
return nil, false, err
}
full := int64(len(body)) < n
return body, full, nil
return provider.ReadPrefix(resp, n)
}
func (p *GitHubProvider) computeSHA256(ctx context.Context, remote models.Remote, downloadURL string) (string, error) {
@@ -627,7 +628,7 @@ func (p *GitHubProvider) computeSHA256(ctx context.Context, remote models.Remote
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("GET %s: status %d", downloadURL, resp.StatusCode)
return "", fmt.Errorf("GET: %w", provider.AssetStatusError(downloadURL, resp))
}
h := sha256.New()
+34 -6
View File
@@ -1,11 +1,13 @@
package rpm
import (
"cmp"
"compress/gzip"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -24,15 +26,22 @@ import (
// fakeStore is an in-memory provider.RemoteMetadataStore keyed by file_path,
// mirroring the (repo_name, file_path) uniqueness of the real table.
type fakeStore struct {
mu sync.Mutex
rows map[string]provider.RPMMetadata
mu sync.Mutex
rows map[string]provider.RPMMetadata
insertFail map[string]int // package name -> inserts left to fail
}
func newFakeStore() *fakeStore { return &fakeStore{rows: map[string]provider.RPMMetadata{}} }
func newFakeStore() *fakeStore {
return &fakeStore{rows: map[string]provider.RPMMetadata{}, insertFail: map[string]int{}}
}
func (f *fakeStore) InsertRPMMetadata(_ context.Context, m *provider.RPMMetadata) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.insertFail[m.Name] > 0 {
f.insertFail[m.Name]--
return errors.New("insert failed")
}
if _, ok := f.rows[m.FilePath]; ok {
return nil // ON CONFLICT DO NOTHING
}
@@ -79,15 +88,20 @@ type githubFixture struct {
releaseAuth string // Authorization header seen on the last releases request
assetAuth string // Authorization header seen on the last asset request
failStatus int // when set, the releases list answers this status (rate-limit style)
assetFail map[string]int // asset filename -> downloads left to answer failCode
failCode int // status for assetFail downloads; 0 = 500
shortRead map[string]int // asset filename -> ranged GETs left to close early
mu sync.Mutex
}
func newGitHubFixture(t *testing.T, withDigest bool) *githubFixture {
t.Helper()
f := &githubFixture{
rpmBytes: map[string][]byte{},
rangeHit: map[string]int{},
fullHit: map[string]int{},
rpmBytes: map[string][]byte{},
rangeHit: map[string]int{},
fullHit: map[string]int{},
assetFail: map[string]int{},
shortRead: map[string]int{},
}
f.rpmBytes["demo-1.2-3.x86_64.rpm"] = testsupport.MinimalRPM("demo", "1.2", "3", "x86_64")
@@ -145,6 +159,17 @@ func newGitHubFixture(t *testing.T, withDigest bool) *githubFixture {
}
rng := r.Header.Get("Range")
f.mu.Lock()
if f.assetFail[name] > 0 {
f.assetFail[name]--
code := cmp.Or(f.failCode, http.StatusInternalServerError)
f.mu.Unlock()
http.Error(w, "boom", code)
return
}
short := rng != "" && f.shortRead[name] > 0
if short {
f.shortRead[name]--
}
f.assetAuth = r.Header.Get("Authorization")
if rng != "" {
f.rangeHit[name]++
@@ -165,6 +190,9 @@ func newGitHubFixture(t *testing.T, withDigest bool) *githubFixture {
end = len(body) - 1
}
w.Header().Set("Content-Range", fmt.Sprintf("bytes 0-%d/%d", end, len(body)))
if short {
end /= 2
}
w.Header().Set("Content-Length", strconv.Itoa(end+1))
w.WriteHeader(http.StatusPartialContent)
w.Write(body[:end+1])
+71
View File
@@ -399,3 +399,74 @@ func TestSyncerFailedScanRetriesAfterBackoff(t *testing.T) {
t.Fatalf("recovery did not refresh metadata: %d rows", len(rows))
}
}
// A transiently failed asset fails the scan: the ETag is not advanced, a retry
// is scheduled, and the retry against the unchanged release list derives it. An
// invalid package is skipped instead, since no retry can fix it.
func TestSyncerFailedAssetRetry(t *testing.T) {
for _, tc := range []struct {
name string
corrupt bool
failCode int
short bool
insert bool
wantRetry bool
}{
{name: "transient", wantRetry: true},
{name: "rate limited", failCode: http.StatusForbidden, wantRetry: true},
{name: "short read", short: true, wantRetry: true},
{name: "insert failure", insert: true, wantRetry: true},
{name: "invalid package", corrupt: true},
{name: "asset not found", failCode: http.StatusNotFound},
{name: "asset gone", failCode: http.StatusGone},
} {
t.Run(tc.name, func(t *testing.T) {
const other = "other-9-9.aarch64.rpm"
fx := newGitHubFixture(t, true)
fx.etag = `"v1"`
fx.rpmBytes[other] = testsupport.MinimalRPM("other", "9", "9", "aarch64")
store := newFakeSyncStore()
switch {
case tc.corrupt:
fx.rpmBytes[other] = []byte("not a package")
case tc.short:
fx.shortRead[other] = 1
case tc.insert:
store.insertFail["other"] = 1
case tc.failCode != 0 && !tc.wantRetry:
fx.failCode, fx.assetFail[other] = tc.failCode, 1<<30
default:
fx.failCode, fx.assetFail[other] = tc.failCode, 1
}
s := newSyncer(store, newTestProvider(), testSyncConfig())
remote := fx.remote()
bg := context.Background()
s.process(bg, syncJob{remote: remote, prime: true})
_, pending := store.retryAt[remote.Name]
if pending != tc.wantRetry {
t.Fatalf("retry pending=%v, want %v", pending, tc.wantRetry)
}
if _, saved := store.etags[remote.Name]; saved == tc.wantRetry {
t.Fatalf("etag saved=%v after first scan", saved)
}
if rows, _ := store.ListRPMMetadataEntries(bg, remote.Name); len(rows) != 1 {
t.Fatalf("healthy asset not served: %d rows", len(rows))
}
if !tc.wantRetry {
return
}
store.mu.Lock()
store.retryAt[remote.Name] = time.Now().Add(-time.Second)
store.mu.Unlock()
s.process(bg, syncJob{remote: remote})
if _, pending := store.retryAt[remote.Name]; pending || store.etags[remote.Name] != `"v1"` {
t.Fatalf("retry not recorded: pending=%v etag=%q", pending, store.etags[remote.Name])
}
if rows, _ := store.ListRPMMetadataEntries(bg, remote.Name); len(rows) != 2 {
t.Fatalf("retry did not derive the failed asset: %d rows", len(rows))
}
})
}
}
+45
View File
@@ -3,8 +3,10 @@ package provider
import (
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
)
@@ -43,6 +45,49 @@ func NewUpstreamStatusError(url string, resp *http.Response) *UpstreamStatusErro
return e
}
// ErrInvalidPackage marks an asset whose bytes are not a valid package. It is
// permanent, so a scan skips the asset instead of failing and retrying.
var ErrInvalidPackage = errors.New("invalid package")
// AssetStatusError classifies a non-success asset download. A missing or
// unsatisfiable asset is permanent; anything else (403/429 rate limits, 5xx) is
// transient.
func AssetStatusError(url string, resp *http.Response) error {
switch resp.StatusCode {
case http.StatusNotFound, http.StatusGone, http.StatusRequestedRangeNotSatisfiable:
return fmt.Errorf("%w: %s: status %d", ErrInvalidPackage, url, resp.StatusCode)
}
return NewUpstreamStatusError(url, resp)
}
// ReadPrefix reads up to n bytes of a 200 or 206 asset response. full reports
// that the body is the whole object (per Content-Length or the Content-Range
// total, or a clean EOF before n when the total is unknown); a body ending before n bytes that is not the whole object is a
// transient short read.
func ReadPrefix(resp *http.Response, n int64) (body []byte, full bool, err error) {
body, err = io.ReadAll(io.LimitReader(resp.Body, n))
if err != nil {
return nil, false, err
}
total := resp.ContentLength
unknown := resp.StatusCode == http.StatusOK && total == -1
if resp.StatusCode == http.StatusPartialContent {
total = -1
if _, t, ok := strings.Cut(resp.Header.Get("Content-Range"), "/"); ok {
if v, perr := strconv.ParseInt(t, 10, 64); perr == nil {
total = v
}
unknown = t == "*"
}
}
// net/http errors on a premature close, so a clean EOF before n with an unknown total is the whole object.
full = total == int64(len(body)) || (unknown && int64(len(body)) < n)
if !full && int64(len(body)) < n {
return nil, false, fmt.Errorf("short read: got %d of %d bytes", len(body), n)
}
return body, full, nil
}
// SyncResult is a background scan's outcome, recorded when its sync lease is
// released. A failed scan keeps the prior sync time and ETag and schedules a
// retry after Backoff, doubled per consecutive failure up to MaxBackoff, and
+75
View File
@@ -3,7 +3,9 @@ package provider
import (
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
@@ -61,3 +63,76 @@ func TestNewSyncResult(t *testing.T) {
t.Fatalf("wrapped hint not clamped to ttl: retry in %v", until)
}
}
func TestAssetStatusErrorClassification(t *testing.T) {
for status, permanent := range map[int]bool{
http.StatusNotFound: true, http.StatusGone: true, http.StatusRequestedRangeNotSatisfiable: true,
http.StatusForbidden: false, http.StatusTooManyRequests: false, http.StatusInternalServerError: false, http.StatusBadGateway: false,
} {
err := AssetStatusError("u", &http.Response{StatusCode: status, Header: http.Header{}})
if errors.Is(err, ErrInvalidPackage) != permanent {
t.Errorf("status %d: permanent=%v, want %v", status, !permanent, permanent)
}
}
}
func TestReadPrefix(t *testing.T) {
cases := []struct {
name string
status int
cl int64
crange string
body string
n int64
wantFull bool
wantErr bool
}{
{name: "200 whole object", status: 200, cl: 5, body: "hello", n: 32, wantFull: true},
{name: "200 larger than n", status: 200, cl: 5, body: "hello", n: 4},
{name: "200 chunked shorter than n", status: 200, cl: -1, body: "hello", n: 32, wantFull: true},
{name: "200 chunked larger than n", status: 200, cl: -1, body: "hello", n: 4},
{name: "206 whole object", status: 206, crange: "bytes 0-4/5", body: "hello", n: 32, wantFull: true},
{name: "206 prefix", status: 206, crange: "bytes 0-3/100", body: "hell", n: 4},
{name: "206 short read", status: 206, crange: "bytes 0-31/100", body: "hello", n: 32, wantErr: true},
{name: "206 no content-range short", status: 206, body: "hello", n: 32, wantErr: true},
{name: "206 unknown total shorter than n", status: 206, cl: -1, crange: "bytes 0-4/*", body: "hello", n: 32, wantFull: true},
{name: "206 unknown total prefix", status: 206, cl: -1, crange: "bytes 0-3/*", body: "hell", n: 4},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if tc.cl > 0 {
w.Header().Set("Content-Length", strconv.FormatInt(tc.cl, 10))
}
if tc.crange != "" {
w.Header().Set("Content-Range", tc.crange)
}
w.WriteHeader(tc.status)
_, _ = io.WriteString(w, tc.body)
w.(http.Flusher).Flush()
}))
defer srv.Close()
resp, err := http.Get(srv.URL)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if tc.cl == -1 && resp.ContentLength != -1 {
t.Fatalf("response not chunked: ContentLength=%d", resp.ContentLength)
}
body, full, err := ReadPrefix(resp, tc.n)
if (err != nil) != tc.wantErr {
t.Fatalf("err=%v, wantErr=%v", err, tc.wantErr)
}
if err != nil {
if errors.Is(err, ErrInvalidPackage) {
t.Fatalf("short read must be transient: %v", err)
}
return
}
if full != tc.wantFull || int64(len(body)) > tc.n {
t.Fatalf("full=%v len=%d, want full=%v", full, len(body), tc.wantFull)
}
})
}
}
+3
View File
@@ -2,6 +2,7 @@ package proxy
import (
"regexp"
"strings"
"sync"
"git.unkin.net/unkin/artifactapi/internal/provider"
@@ -37,7 +38,9 @@ func NewClassifier(p provider.Provider) *Classifier {
return &Classifier{provider: p}
}
// Classify judges the bare path; a forwarded query must not change the verdict.
func (c *Classifier) Classify(remote models.Remote, path string) Classification {
path, _, _ = strings.Cut(path, "?")
if matchesAny(path, compilePatterns(remote.Blocklist)) {
return ClassDenied
}
+18
View File
@@ -38,6 +38,24 @@ func TestClassifierBranches(t *testing.T) {
}
}
func TestClassifierIgnoresForwardedQuery(t *testing.T) {
gp, err := provider.Get(models.PackageGeneric)
if err != nil {
t.Fatal(err)
}
c := NewClassifier(gp)
if got := c.Classify(models.Remote{Blocklist: []string{`\.exe$`}}, "x.exe?limit=1"); got != ClassDenied {
t.Errorf("blocklist bypassed by query: %v", got)
}
allow := models.Remote{Patterns: []string{`/versions/$`}}
if got := c.Classify(allow, "a/versions/?limit=1&offset=100"); got != ClassImmutable {
t.Errorf("allowlisted paginated path = %v, want immutable", got)
}
if got := c.Classify(models.Remote{MutablePatterns: []string{`/versions/$`}}, "a/versions/?offset=100"); got != ClassMutable {
t.Errorf("mutable pattern on paginated path = %v, want mutable", got)
}
}
func TestClassificationString(t *testing.T) {
for c, want := range map[Classification]string{
ClassImmutable: "immutable",
+29 -14
View File
@@ -134,10 +134,11 @@ func (e *Engine) Fetch(ctx context.Context, remote models.Remote, path string, p
slog.Warn("cache hit but S3 miss, re-fetching", "remote", remote.Name, "path", path)
}
locked, err := e.cache.AcquireLock(ctx, remote.Name, path, fetchLockTTL)
token, err := e.cache.AcquireLock(ctx, remote.Name, path, fetchLockTTL)
if err != nil {
slog.Warn("lock acquire failed", "error", err)
}
locked := token != ""
if !locked {
// Another request holds the fetch lock. Poll the store until the leader
@@ -151,7 +152,7 @@ func (e *Engine) Fetch(ctx context.Context, remote models.Remote, path string, p
}
if locked {
defer e.cache.ReleaseLock(ctx, remote.Name, path)
defer func() { _ = e.cache.ReleaseLock(context.WithoutCancel(ctx), remote.Name, path, token) }()
}
if class == ClassMutable && remote.CheckMutable {
@@ -216,7 +217,8 @@ func (e *Engine) Fetch(ctx context.Context, remote models.Remote, path string, p
// Evict drops path from every cache layer (artifact row, index object, Redis
// freshness and ETag keys) so the next request refetches from upstream. A
// trailing "/*" evicts every path under that directory.
// trailing "/*" evicts every path under that directory; a plain path also
// drops its forwarded-query variants ("path?...").
func (e *Engine) Evict(ctx context.Context, remoteName, path string) error {
prefix, wildcard := strings.CutSuffix(path, "*")
if wildcard && !strings.HasSuffix(prefix, "/") {
@@ -239,11 +241,23 @@ func (e *Engine) Evict(ctx context.Context, remoteName, path string) error {
if err := e.store.Delete(ctx, storage.IndexKey(remoteName, path)); err != nil {
return fmt.Errorf("delete index: %w", err)
}
return e.cache.ForgetPath(ctx, remoteName, path)
if err := e.cache.ForgetPath(ctx, remoteName, path); err != nil {
return err
}
if strings.Contains(path, "?") {
return nil
}
return e.evictPrefix(ctx, remoteName, path+"?")
}
// ponytail: no lock for wildcards; a Fetch already in flight under the
// prefix can re-cache its path after the evict. Per-path locks over a
// directory would close it if that ever matters.
return e.evictPrefix(ctx, remoteName, prefix)
}
// evictPrefix drops every cached path starting with prefix, including the
// forwarded-query variants ("path?...") of a single path.
func (e *Engine) evictPrefix(ctx context.Context, remoteName, prefix string) error {
if err := e.db.DeleteArtifactsByPrefix(ctx, remoteName, prefix); err != nil {
return fmt.Errorf("delete artifacts: %w", err)
}
@@ -257,31 +271,32 @@ func (e *Engine) Evict(ctx context.Context, remoteName, path string) error {
// release. It fails with a 503 when the lock cannot be taken within wait or
// Redis errors.
func (e *Engine) Lock(ctx context.Context, remoteName, path string, wait, ttl time.Duration) (func(), error) {
if err := e.waitForLock(ctx, remoteName, path, wait, ttl); err != nil {
token, err := e.waitForLock(ctx, remoteName, path, wait, ttl)
if err != nil {
return nil, err
}
return func() { _ = e.cache.ReleaseLock(context.WithoutCancel(ctx), remoteName, path) }, nil
return func() { _ = e.cache.ReleaseLock(context.WithoutCancel(ctx), remoteName, path, token) }, nil
}
func (e *Engine) waitForLock(ctx context.Context, remoteName, path string, wait, ttl time.Duration) error {
func (e *Engine) waitForLock(ctx context.Context, remoteName, path string, wait, ttl time.Duration) (string, error) {
deadline := time.Now().Add(wait)
for {
ok, err := e.cache.AcquireLock(ctx, remoteName, path, ttl)
if ok {
return nil
token, err := e.cache.AcquireLock(ctx, remoteName, path, ttl)
if token != "" {
return token, nil
}
if ctx.Err() != nil {
return ctx.Err()
return "", ctx.Err()
}
if err != nil {
return &ProxyError{Status: http.StatusServiceUnavailable, Message: fmt.Sprintf("fetch lock: %v", err)}
return "", &ProxyError{Status: http.StatusServiceUnavailable, Message: fmt.Sprintf("lock: %v", err)}
}
if time.Now().After(deadline) {
return &ProxyError{Status: http.StatusServiceUnavailable, Message: "fetch in progress, retry evict"}
return "", &ProxyError{Status: http.StatusServiceUnavailable, Message: "resource busy, retry later"}
}
select {
case <-ctx.Done():
return ctx.Err()
return "", ctx.Err()
case <-time.After(50 * time.Millisecond):
}
}
+36 -9
View File
@@ -152,13 +152,15 @@ func TestEvictUnknownRemote(t *testing.T) {
}
}
func holdLock(t *testing.T, remote, path string) {
func holdLock(t *testing.T, remote, path string) string {
t.Helper()
ctx := context.Background()
if ok, err := testCache.AcquireLock(ctx, remote, path, time.Minute); !ok || err != nil {
t.Fatalf("acquire: %v %v", ok, err)
token, err := testCache.AcquireLock(ctx, remote, path, time.Minute)
if token == "" || err != nil {
t.Fatalf("acquire: %q %v", token, err)
}
t.Cleanup(func() { _ = testCache.ReleaseLock(ctx, remote, path) })
t.Cleanup(func() { _ = testCache.ReleaseLock(ctx, remote, path, token) })
return token
}
func TestEvictWaitsForFetchLock(t *testing.T) {
@@ -167,7 +169,7 @@ func TestEvictWaitsForFetchLock(t *testing.T) {
r := rpmRemote(t, "evict-lock", srv.URL)
ctx := context.Background()
const path = "8/Everything/x86_64/repodata/repomd.xml"
holdLock(t, r.Name, path)
token := holdLock(t, r.Name, path)
done := make(chan error, 1)
go func() { done <- testEngine.Evict(ctx, r.Name, path) }()
select {
@@ -175,13 +177,13 @@ func TestEvictWaitsForFetchLock(t *testing.T) {
t.Fatalf("evict returned while fetch lock held: %v", err)
case <-time.After(200 * time.Millisecond):
}
_ = testCache.ReleaseLock(ctx, r.Name, path)
_ = testCache.ReleaseLock(ctx, r.Name, path, token)
if err := <-done; err != nil {
t.Fatalf("evict: %v", err)
}
ok, err := testCache.AcquireLock(ctx, r.Name, path, time.Second)
if !ok || err != nil {
t.Fatalf("lock still held after evict: %v %v", ok, err)
token, err := testCache.AcquireLock(ctx, r.Name, path, time.Second)
if token == "" || err != nil {
t.Fatalf("lock still held after evict: %q %v", token, err)
}
}
@@ -269,3 +271,28 @@ func TestEvictWildcardClearsPrefixOnly(t *testing.T) {
t.Errorf("path outside prefix = %q, want cached rev1", got)
}
}
func TestEvictPathDropsQueryVariants(t *testing.T) {
requireStack(t)
srv, _, _ := changingUpstream(t)
r := rpmRemote(t, "evict-query", srv.URL)
const (
path = "8/Everything/x86_64/repodata/repomd.xml"
variant = path + "?limit=10"
sibling = "8/Everything/x86_64/repodata/repomd.xml.asc"
)
for _, p := range []string{path, variant, sibling} {
fetchBody(t, r, p)
}
if err := testEngine.Evict(context.Background(), r.Name, path); err != nil {
t.Fatalf("evict: %v", err)
}
for _, p := range []string{path, variant} {
if c := layers(t, r.Name, p); c != (cached{}) {
t.Errorf("%s after evict = %+v, want every layer gone", p, c)
}
}
if c := layers(t, r.Name, sibling); c == (cached{}) {
t.Errorf("%s sharing the name prefix was evicted", sibling)
}
}
+1
View File
@@ -168,6 +168,7 @@ func (s *Server) routes() chi.Router {
proxyHandler := v1.NewProxyHandler(s.engine, s.virtEngine, s.db, s.store, s.localHandler)
s.virtEngine.NPMLocal = proxyHandler.LocalNPMPackument
s.virtEngine.GemsLocal = proxyHandler.LocalGemsIndex
s.virtEngine.AnsibleLocal = proxyHandler.LocalAnsible
r.Mount("/api/v1", proxyHandler.Routes())
r.Mount("/v2", proxyHandler.DockerV2Routes())
+182
View File
@@ -0,0 +1,182 @@
package virtual
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"git.unkin.net/unkin/artifactapi/internal/provider/ansible"
"git.unkin.net/unkin/artifactapi/internal/proxy"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
const (
maxAnsibleCollections = 1024
ansibleMemberPage = 100
maxAnsibleMemberPages = 100
)
// AnsibleMember is one member's versions of a collection; Found is false when
// the member does not have it.
type AnsibleMember struct {
RemoteName string
Found bool
Refs []ansible.VersionRef
}
// AnsibleDoc is a collection's merged versions; Owners maps each version to
// the member serving it.
type AnsibleDoc struct {
Refs []ansible.VersionRef
Owners map[string]string
body []byte
}
func (d *AnsibleDoc) index() []byte { return d.body }
func (d *AnsibleDoc) files() map[string][]byte { return nil }
// MergeAnsibleVersions concatenates member versions in member order; the
// first member wins a duplicate version. ErrNotFound means no member has the
// collection.
func MergeAnsibleVersions(members []AnsibleMember) (*AnsibleDoc, error) {
d := &AnsibleDoc{Refs: []ansible.VersionRef{}, Owners: map[string]string{}}
found := false
for _, m := range members {
found = found || m.Found
for _, r := range m.Refs {
if _, dup := d.Owners[r.Version]; dup {
continue
}
d.Owners[r.Version] = m.RemoteName
d.Refs = append(d.Refs, r)
}
}
if !found {
return nil, ErrNotFound
}
var err error
d.body, err = json.Marshal(d.Refs)
return d, err
}
// AnsibleVersions returns the merged versions of ns.name across members.
func (e *Engine) AnsibleVersions(ctx context.Context, virt models.Virtual, ns, name string) (*AnsibleDoc, error) {
prefix := "ansible\x00" + virt.Name + "\x00"
inserted := false
repo, err := e.merged(ctx, prefix+ns+"/"+name, func(ctx context.Context) (mergedRepo, error) {
inserted = true
members, err := fetchMembers(virt, func(m string) (*AnsibleMember, error) { return e.ansibleMember(ctx, m, ns, name) })
if err != nil {
return nil, err
}
return MergeAnsibleVersions(members)
})
if err != nil {
return nil, err
}
if inserted {
e.evictOldest(prefix, maxAnsibleCollections)
}
return repo.(*AnsibleDoc), nil
}
// AnsibleVersion serves the owning member's version detail with href and
// download_url pointing at the virtual; the download 302s to the member.
// Member documents carry root-relative URLs, so anything else is rejected.
func (e *Engine) AnsibleVersion(ctx context.Context, virt models.Virtual, ns, name, version, proxyBaseURL string) ([]byte, error) {
d, err := e.AnsibleVersions(ctx, virt, ns, name)
if err != nil {
return nil, err
}
owner, ok := d.Owners[version]
if !ok {
return nil, ErrNotFound
}
remote, err := e.getRemote(ctx, owner)
if err != nil {
return nil, fmt.Errorf("remote %q: %w", owner, err)
}
body, err := e.ansibleDoc(ctx, *remote, ansible.VersionPath(ns, name, version)[1:])
if err != nil {
return nil, fmt.Errorf("member %q: %v", owner, err)
}
var detail map[string]any
if err := json.Unmarshal(body, &detail); err != nil {
return nil, fmt.Errorf("member %q version detail: %v", owner, err)
}
dl, _ := detail["download_url"].(string)
u, err := url.Parse(dl)
if err != nil {
return nil, fmt.Errorf("member %q download_url %q: %v", owner, dl, err)
}
rest, ok := strings.CutPrefix(u.EscapedPath(), "/api/v1/"+string(remote.RepoType)+"/"+url.PathEscape(owner)+"/")
if !ok || u.Scheme != "" || u.Host != "" {
return nil, fmt.Errorf("member %q download_url %q is not served by the member", owner, dl)
}
base := strings.TrimRight(proxyBaseURL, "/") + "/api/v1/virtual/" + url.PathEscape(virt.Name)
detail["download_url"] = base + "/" + url.PathEscape(owner) + "/" + rest
detail["href"] = base + ansible.VersionPath(ns, name, version)
return json.Marshal(detail)
}
// ansibleMember reads every version a member lists for ns.name, following
// the member's pagination for remotes.
func (e *Engine) ansibleMember(ctx context.Context, member, ns, name string) (*AnsibleMember, error) {
remote, err := e.getRemote(ctx, member)
if err != nil {
return nil, fmt.Errorf("remote %q: %w", member, err)
}
m := &AnsibleMember{RemoteName: member}
list := ansible.CollectionPath(ns, name)[1:] + "versions/"
for page := 0; page < maxAnsibleMemberPages; page++ {
// Locals list every version at once; remote pages match the keys the
// remote caches client requests under.
path := list
if remote.RepoType != models.RepoTypeLocal {
path += "?limit=" + strconv.Itoa(ansibleMemberPage)
if page > 0 {
path += "&offset=" + strconv.Itoa(len(m.Refs))
}
}
body, err := e.ansibleDoc(ctx, *remote, path)
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 {
Meta struct{ Count int }
Data []ansible.VersionRef
}
if err := json.Unmarshal(body, &doc); err != nil {
return nil, fmt.Errorf("%s/%s: %v", member, path, err)
}
m.Found = true
m.Refs = append(m.Refs, doc.Data...)
if remote.RepoType == models.RepoTypeLocal || len(m.Refs) >= doc.Meta.Count {
return m, nil
}
if len(doc.Data) == 0 {
return nil, fmt.Errorf("%s/%s: empty page after %d of %d versions", member, path, len(m.Refs), doc.Meta.Count)
}
}
return nil, fmt.Errorf("%s: more than %d pages of %s.%s versions", member, maxAnsibleMemberPages, ns, name)
}
// ansibleMemberDoc reads a Galaxy API document from a member.
func (e *Engine) ansibleMemberDoc(ctx context.Context, remote models.Remote, path string) ([]byte, error) {
if remote.RepoType != models.RepoTypeLocal {
return e.fetchMemberPath(ctx, remote, path)
}
if e.AnsibleLocal == nil {
return nil, errors.New("local ansible indexes are not wired")
}
return e.AnsibleLocal(ctx, remote.Name, path)
}
+206
View File
@@ -0,0 +1,206 @@
package virtual
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"sync"
"testing"
"time"
"git.unkin.net/unkin/artifactapi/internal/provider/ansible"
"git.unkin.net/unkin/artifactapi/internal/proxy"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
func refs(vs ...string) []ansible.VersionRef {
out := make([]ansible.VersionRef, len(vs))
for i, v := range vs {
out[i] = ansible.VersionRef{Version: v}
}
return out
}
func TestMergeAnsibleVersionsMemberOrderWins(t *testing.T) {
d, err := MergeAnsibleVersions([]AnsibleMember{
{RemoteName: "internal", Found: true, Refs: refs("2.0.0", "1.0.0")},
{RemoteName: "missing"},
{RemoteName: "galaxy", Found: true, Refs: refs("3.0.0", "2.0.0", "0.9.0")},
})
if err != nil {
t.Fatal(err)
}
var got []string
for _, r := range d.Refs {
got = append(got, r.Version)
}
if strings.Join(got, ",") != "2.0.0,1.0.0,3.0.0,0.9.0" {
t.Fatalf("merged = %v", got)
}
for v, owner := range map[string]string{"2.0.0": "internal", "1.0.0": "internal", "3.0.0": "galaxy", "0.9.0": "galaxy"} {
if d.Owners[v] != owner {
t.Errorf("owner of %s = %q, want %q", v, d.Owners[v], owner)
}
}
if _, err := MergeAnsibleVersions([]AnsibleMember{{RemoteName: "a"}, {RemoteName: "b"}}); !errors.Is(err, ErrNotFound) {
t.Fatalf("no member has it: want ErrNotFound, got %v", err)
}
if d, err := MergeAnsibleVersions([]AnsibleMember{{RemoteName: "a", Found: true}}); err != nil || len(d.Refs) != 0 {
t.Fatalf("found with no versions = %v %v", d, err)
}
}
// galaxyPages answers a remote's versions list like Galaxy, two per page.
func galaxyPages(versions []string, path string) ([]byte, error) {
_, q, _ := strings.Cut(path, "?")
offset := 0
if _, off, ok := strings.Cut(q, "offset="); ok {
_, _ = fmt.Sscan(off, &offset)
}
end := min(offset+2, len(versions))
return json.Marshal(map[string]any{"meta": map[string]int{"count": len(versions)}, "data": refs(versions[min(offset, end):end]...)})
}
func ansibleEngine(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
},
ansibleDoc: func(_ context.Context, remote models.Remote, path string) ([]byte, error) { return doc(remote, path) },
}
}
func ansibleVirt(members ...string) models.Virtual {
return models.Virtual{Name: "ansible", PackageType: models.PackageAnsible, Members: members}
}
func TestAnsibleVersionsFollowsRemotePages(t *testing.T) {
var mu sync.Mutex
var asked []string
e := ansibleEngine(func(remote models.Remote, path string) ([]byte, error) {
mu.Lock()
asked = append(asked, remote.Name+" "+path)
mu.Unlock()
if remote.Name == "internal" {
return galaxyPages([]string{"1.0.0", "5.0.0"}, "")
}
return galaxyPages([]string{"5.0.0", "4.0.0", "3.0.0", "2.0.0", "1.5.0"}, path)
})
virt := ansibleVirt("internal", "galaxy")
d, err := e.AnsibleVersions(context.Background(), virt, "community", "general")
if err != nil {
t.Fatal(err)
}
if len(d.Refs) != 6 || d.Owners["5.0.0"] != "internal" || d.Owners["1.5.0"] != "galaxy" {
t.Fatalf("merged = %+v", d)
}
want := map[string]bool{
"internal api/v3/collections/community/general/versions/": true,
"galaxy api/v3/collections/community/general/versions/?limit=100": true,
"galaxy api/v3/collections/community/general/versions/?limit=100&offset=2": true,
"galaxy api/v3/collections/community/general/versions/?limit=100&offset=4": true,
}
if len(asked) != len(want) {
t.Fatalf("asked %v", asked)
}
for _, a := range asked {
if !want[a] {
t.Errorf("unexpected fetch %q", a)
}
}
if _, err := e.AnsibleVersions(context.Background(), virt, "community", "general"); err != nil || len(asked) != len(want) {
t.Fatalf("merge not cached: %v, %d fetches", err, len(asked))
}
}
func TestAnsibleVersionsFailsClosed(t *testing.T) {
e := ansibleEngine(func(remote models.Remote, path string) ([]byte, error) {
switch remote.Name {
case "internal":
return nil, ErrNotFound
case "gone":
return nil, &proxy.ProxyError{Status: http.StatusNotFound}
}
return nil, errors.New("upstream down")
})
if _, err := e.AnsibleVersions(context.Background(), ansibleVirt("internal", "gone"), "a", "b"); !errors.Is(err, ErrNotFound) {
t.Fatalf("all members lack it: want ErrNotFound, got %v", err)
}
if _, err := e.AnsibleVersions(context.Background(), ansibleVirt("internal", "galaxy"), "a", "b"); err == nil || errors.Is(err, ErrNotFound) {
t.Fatalf("member error: want a non-404 error, got %v", err)
}
}
func TestAnsibleVersionsShortRemote(t *testing.T) {
e := ansibleEngine(func(_ models.Remote, path string) ([]byte, error) {
if strings.Contains(path, "offset=") {
return json.Marshal(map[string]any{"meta": map[string]int{"count": 3}, "data": []any{}})
}
return galaxyPages([]string{"3.0.0", "2.0.0", "1.0.0"}, path)
})
if _, err := e.AnsibleVersions(context.Background(), ansibleVirt("galaxy"), "a", "b"); err == nil || errors.Is(err, ErrNotFound) {
t.Fatalf("empty page before count: want a non-404 error, got %v", err)
}
}
func TestAnsibleVersionRoutesViaVirtual(t *testing.T) {
detail := func(dl string) []byte {
b, _ := json.Marshal(map[string]any{"version": "1.0.0", "download_url": dl, "artifact": map[string]string{"sha256": "ab"}})
return b
}
dls := map[string]string{
"internal": "/api/v1/local/internal/artifacts/a-b-1.0.0.tar.gz",
"galaxy": "/api/v1/remote/galaxy/api/v3/plugin/ansible/content/published/collections/artifacts/a-b-2.0.0.tar.gz",
"evil": "https://evil.example/api/v1/remote/evil/x.tar.gz",
"other": "/api/v1/remote/someoneelse/x.tar.gz",
}
e := ansibleEngine(func(remote models.Remote, path string) ([]byte, error) {
if strings.Contains(path, "/versions/?") || strings.HasSuffix(path, "/versions/") {
v := map[string]string{"internal": "1.0.0", "galaxy": "2.0.0", "evil": "3.0.0", "other": "4.0.0"}[remote.Name]
return galaxyPages([]string{v}, path)
}
return detail(dls[remote.Name]), nil
})
virt := ansibleVirt("internal", "galaxy", "evil", "other")
for v, want := range map[string]string{
"1.0.0": "https://h/api/v1/virtual/ansible/internal/artifacts/a-b-1.0.0.tar.gz",
"2.0.0": "https://h/api/v1/virtual/ansible/galaxy/api/v3/plugin/ansible/content/published/collections/artifacts/a-b-2.0.0.tar.gz",
} {
body, err := e.AnsibleVersion(context.Background(), virt, "a", "b", v, "https://h")
if err != nil {
t.Fatal(err)
}
var got struct {
Href string
DownloadURL string `json:"download_url"`
Artifact struct{ Sha256 string }
}
_ = json.Unmarshal(body, &got)
if got.DownloadURL != want || got.Href != "https://h/api/v1/virtual/ansible/api/v3/collections/a/b/versions/"+v+"/" || got.Artifact.Sha256 != "ab" {
t.Errorf("%s detail = %+v", v, got)
}
loc, ok, err := e.MemberRedirect(context.Background(), virt, strings.TrimPrefix(want, "https://h/api/v1/virtual/ansible/"), "", "https://h")
if err != nil || !ok || loc != "https://h"+dls[map[string]string{"1.0.0": "internal", "2.0.0": "galaxy"}[v]] {
t.Errorf("%s redirect = %q %v %v", v, loc, ok, err)
}
}
for _, v := range []string{"3.0.0", "4.0.0"} {
if _, err := e.AnsibleVersion(context.Background(), virt, "a", "b", v, "https://h"); err == nil || errors.Is(err, ErrNotFound) {
t.Errorf("%s: foreign download_url accepted: %v", v, err)
}
}
if _, err := e.AnsibleVersion(context.Background(), virt, "a", "b", "9.9.9", "https://h"); !errors.Is(err, ErrNotFound) {
t.Errorf("unknown version: want ErrNotFound, got %v", err)
}
if _, _, err := e.MemberRedirect(context.Background(), virt, "galaxy/../internal/x", "", "https://h"); !errors.Is(err, ErrBadPath) {
t.Errorf("escaping redirect: want ErrBadPath, got %v", err)
}
}
+17 -4
View File
@@ -38,6 +38,13 @@ type Engine struct {
// ErrNotFound.
GemsLocal func(ctx context.Context, repo, path string) ([]byte, error)
// AnsibleLocal returns a local ansible repo's collection, full versions list
// or version detail with root-relative URLs, or ErrNotFound.
AnsibleLocal func(ctx context.Context, repo, path string) ([]byte, error)
ansibleDoc func(ctx context.Context, remote models.Remote, path string) ([]byte, error)
goMember func(ctx context.Context, name, path string) (*GoMember, error)
mergeTTL time.Duration
sf singleflight.Group
@@ -68,6 +75,8 @@ func NewEngine(db *database.DB, proxyEngine *proxy.Engine) *Engine {
e.rpmMember = e.fetchRPMMember
e.npmMember = e.fetchNPMMember
e.gemsMember = e.fetchGemsMember
e.ansibleDoc = e.ansibleMemberDoc
e.goMember = e.fetchGoMember
return e
}
@@ -192,16 +201,17 @@ var (
ErrBadPath = errors.New("invalid path")
)
// MemberRedirect maps an rpm or npm virtual package path (<member>/<href>, as
// written by MergeRPM/MergeNPM) 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, 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.
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.PackageNPM:
case models.PackageNPM, models.PackageAnsible:
default:
return "", false, nil
}
@@ -421,6 +431,9 @@ func (e *Engine) fetchMemberPath(ctx context.Context, remote models.Remote, path
if serve != nil {
rec := httptest.NewRecorder()
if serve(rec, httptest.NewRequestWithContext(ctx, http.MethodGet, "/"+path, nil)) {
if rec.Code == http.StatusNotFound || rec.Code == http.StatusGone {
return nil, fmt.Errorf("%s/%s: %w", remote.Name, path, ErrNotFound)
}
if rec.Code != http.StatusOK {
return nil, fmt.Errorf("%s/%s: status %d", remote.Name, path, rec.Code)
}
+228
View File
@@ -0,0 +1,228 @@
package virtual
import (
"context"
"errors"
"fmt"
"maps"
"net/http"
"slices"
"strings"
"golang.org/x/mod/module"
"golang.org/x/mod/semver"
"git.unkin.net/unkin/artifactapi/internal/provider/goproxy"
"git.unkin.net/unkin/artifactapi/internal/proxy"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
const maxGoModules = 1024
// GoMember is one member's response for a module path; Body is nil when the
// member does not have it (404/410).
type GoMember struct {
RemoteName string
Body []byte
}
// GoDoc is a goproxy virtual's merged @v/list for one module. Owners maps each
// version to the first member listing it; missing caches that no member has it.
type GoDoc struct {
Body []byte
Owners map[string]string
Latest string
missing bool
}
func (d *GoDoc) index() []byte {
if d.missing {
return nil
}
b := append(slices.Clone(d.Body), 0)
for _, v := range slices.Sorted(maps.Keys(d.Owners)) {
b = append(b, v+"="+d.Owners[v]+"\n"...)
}
return b
}
func (d *GoDoc) files() map[string][]byte { return nil }
// MergeGoList unions member @v/list documents, sorted by semver; the first
// member listing a version owns it. ErrNotFound means no member has the module.
func MergeGoList(members []GoMember) (*GoDoc, error) {
d := &GoDoc{Owners: map[string]string{}}
found := false
var vs []string
for _, m := range members {
if m.Body == nil {
continue
}
found = true
for line := range strings.Lines(string(m.Body)) {
f := strings.Fields(line)
if len(f) == 0 || !semver.IsValid(f[0]) {
continue
}
if _, dup := d.Owners[f[0]]; dup {
continue
}
d.Owners[f[0]] = m.RemoteName
vs = append(vs, f[0])
}
}
if !found {
return nil, ErrNotFound
}
semver.Sort(vs)
for _, v := range vs {
d.Body = append(d.Body, v+"\n"...)
}
if d.Body == nil {
d.Body = []byte{}
}
d.Latest = goproxy.Latest(vs)
return d, nil
}
// GoProxy serves a goproxy virtual path: the merged <module>/@v/list body, or a
// root-relative redirect for @latest and @v/<version>.{info,mod,zip} to the
// first member, in member order, that has that version.
func (e *Engine) GoProxy(ctx context.Context, virt models.Virtual, path, rawQuery string) (body []byte, loc string, err error) {
escMod, rest, ok := strings.Cut(path, "/@")
if !ok {
return nil, "", ErrNotFound
}
if _, err := module.UnescapePath(escMod); err != nil {
return nil, "", ErrNotFound
}
if rest == "v/list" {
d, err := e.goList(ctx, virt, escMod)
if err != nil {
return nil, "", err
}
return d.Body, "", nil
}
var target, owner string
var d *GoDoc
switch {
case rest == "latest":
if d, err = e.goList(ctx, virt, escMod); err != nil {
return nil, "", err
}
if d.Latest == "" {
target = path
owner, err = e.goProbe(ctx, virt, target)
break
}
escVer, _ := module.EscapeVersion(d.Latest)
target, owner = escMod+"/@v/"+escVer+".info", d.Owners[d.Latest]
case strings.HasPrefix(rest, "v/"):
escVer, ext, ok := cutGoExt(rest[len("v/"):])
if !ok {
return nil, "", ErrNotFound
}
ver, verr := module.UnescapeVersion(escVer)
if verr != nil {
return nil, "", ErrNotFound
}
if d, err = e.goList(ctx, virt, escMod); err != nil && !errors.Is(err, ErrNotFound) {
return nil, "", err
}
if d != nil {
owner = d.Owners[ver]
}
if owner == "" {
owner, err = e.goProbe(ctx, virt, escMod+"/@v/"+escVer+".info")
}
target = escMod + "/@v/" + escVer + "." + ext
default:
return nil, "", ErrNotFound
}
if err != nil {
return nil, "", err
}
loc, ok, err = e.memberLocation(ctx, owner, target, "", "")
if err != nil || !ok {
return nil, "", errors.Join(err, ErrNotFound)
}
// module paths and versions are validated, so the only escape is the case-encoding '!'
loc = strings.ReplaceAll(loc, "%21", "!")
if rawQuery != "" {
loc += "?" + rawQuery
}
return nil, loc, nil
}
func cutGoExt(file string) (escVer, ext string, ok bool) {
dot := strings.LastIndex(file, ".")
if dot <= 0 || strings.Contains(file, "/") {
return "", "", false
}
switch ext = file[dot+1:]; ext {
case "info", "mod", "zip":
return file[:dot], ext, true
}
return "", "", false
}
func (e *Engine) goList(ctx context.Context, virt models.Virtual, escMod string) (*GoDoc, error) {
prefix := "goproxy\x00" + virt.Name + "\x00"
inserted := false
repo, err := e.merged(ctx, prefix+escMod, func(ctx context.Context) (mergedRepo, error) {
inserted = true
members, err := fetchMembers(virt, func(name string) (*GoMember, error) { return e.goMember(ctx, name, escMod+"/@v/list") })
if err != nil {
return nil, err
}
d, err := MergeGoList(members)
if errors.Is(err, ErrNotFound) {
return &GoDoc{missing: true}, nil
}
return d, err
})
if err != nil {
return nil, err
}
if inserted {
e.evictOldest(prefix, maxGoModules)
}
if d := repo.(*GoDoc); !d.missing {
return d, nil
}
return nil, ErrNotFound
}
// goProbe returns the first member, in member order, that serves path.
func (e *Engine) goProbe(ctx context.Context, virt models.Virtual, path string) (string, error) {
for _, name := range virt.Members {
m, err := e.goMember(ctx, name, path)
if err != nil {
return "", fmt.Errorf("virtual %q: member %q: %v", virt.Name, name, err)
}
if m.Body != nil {
return name, nil
}
}
return "", ErrNotFound
}
func (e *Engine) fetchGoMember(ctx context.Context, name, path string) (*GoMember, error) {
remote, err := e.getRemote(ctx, name)
if err != nil {
return nil, fmt.Errorf("remote %q: %w", name, err)
}
body, err := e.fetchMemberPath(ctx, *remote, path)
var pe *proxy.ProxyError
if errors.Is(err, ErrNotFound) || errors.As(err, &pe) && (pe.Status == http.StatusNotFound || pe.Status == http.StatusGone) {
return &GoMember{RemoteName: name}, nil
}
if err != nil {
return nil, err
}
if body == nil {
body = []byte{}
}
return &GoMember{RemoteName: name, Body: body}, nil
}
+183
View File
@@ -0,0 +1,183 @@
package virtual
import (
"context"
"errors"
"sync"
"testing"
"time"
"git.unkin.net/unkin/artifactapi/internal/database"
"git.unkin.net/unkin/artifactapi/internal/testsupport"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
func TestMergeGoList(t *testing.T) {
d, err := MergeGoList([]GoMember{
{RemoteName: "internal", Body: []byte("v0.2.0\nv0.1.0\n")},
{RemoteName: "absent"},
{RemoteName: "goproxy", Body: []byte("v0.1.0 extra\nv1.0.0-rc.1\n\nnot-a-version\nv0.3.0\n")},
})
if err != nil {
t.Fatal(err)
}
if want := "v0.1.0\nv0.2.0\nv0.3.0\nv1.0.0-rc.1\n"; string(d.Body) != want {
t.Fatalf("list = %q, want %q", d.Body, want)
}
if d.Latest != "v0.3.0" {
t.Fatalf("latest = %q, want the highest release over the prerelease", d.Latest)
}
for v, owner := range map[string]string{"v0.1.0": "internal", "v0.2.0": "internal", "v0.3.0": "goproxy", "v1.0.0-rc.1": "goproxy"} {
if d.Owners[v] != owner {
t.Errorf("owner of %s = %q, want %q", v, d.Owners[v], owner)
}
}
d, _ = MergeGoList([]GoMember{{RemoteName: "a", Body: []byte("v1.0.0-beta\nv1.0.0-alpha\n")}})
if d.Latest != "v1.0.0-beta" {
t.Fatalf("latest with only prereleases = %q", d.Latest)
}
if d, err := MergeGoList([]GoMember{{RemoteName: "a", Body: []byte{}}}); err != nil || string(d.Body) != "" || d.Latest != "" {
t.Fatalf("empty list must merge to an empty 200 body: %v", err)
}
if _, err := MergeGoList([]GoMember{{RemoteName: "a"}, {RemoteName: "b"}}); !errors.Is(err, ErrNotFound) {
t.Fatalf("want ErrNotFound, got %v", err)
}
}
func goEngine(docs map[string]string, fail string) (*Engine, map[string]int) {
var mu sync.Mutex
asked := map[string]int{}
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
},
goMember: func(_ context.Context, name, path string) (*GoMember, error) {
mu.Lock()
asked[name+" "+path]++
mu.Unlock()
if name == fail {
return nil, errors.New("upstream down")
}
if d, ok := docs[name+" "+path]; ok {
return &GoMember{RemoteName: name, Body: []byte(d)}, nil
}
return &GoMember{RemoteName: name}, nil
},
}, asked
}
func goVirt() models.Virtual {
return models.Virtual{Name: "go", PackageType: models.PackageGoProxy, Members: []string{"internal", "goproxy"}}
}
func TestGoProxyRedirectsToFirstOwner(t *testing.T) {
e, asked := goEngine(map[string]string{
"internal example.unkin.net/!hello/@v/list": "v0.1.0\nv0.2.0\n",
"goproxy example.unkin.net/!hello/@v/list": "v0.2.0\nv0.3.0-rc.1\n",
"goproxy golang.org/x/text/@v/list": "v0.1.0\n",
"goproxy example.unkin.net/!hello/@v/v0.0.0-20260101-abcdef.info": "{}",
"goproxy example.com/notags/@v/list": "",
"goproxy example.com/notags/@latest": "{}",
}, "")
virt := goVirt()
body, loc, err := e.GoProxy(context.Background(), virt, "example.unkin.net/!hello/@v/list", "")
if err != nil || loc != "" || string(body) != "v0.1.0\nv0.2.0\nv0.3.0-rc.1\n" {
t.Fatalf("list = %q %q %v", body, loc, err)
}
for path, want := range map[string]string{
"example.unkin.net/!hello/@v/v0.2.0.zip": "/api/v1/local/internal/example.unkin.net/!hello/@v/v0.2.0.zip",
"example.unkin.net/!hello/@v/v0.3.0-rc.1.mod": "/api/v1/remote/goproxy/example.unkin.net/!hello/@v/v0.3.0-rc.1.mod",
"example.unkin.net/!hello/@latest": "/api/v1/local/internal/example.unkin.net/!hello/@v/v0.2.0.info",
"example.unkin.net/!hello/@v/v0.0.0-20260101-abcdef.zip": "/api/v1/remote/goproxy/example.unkin.net/!hello/@v/v0.0.0-20260101-abcdef.zip",
"golang.org/x/text/@v/v0.1.0.info": "/api/v1/remote/goproxy/golang.org/x/text/@v/v0.1.0.info",
"example.com/notags/@latest": "/api/v1/remote/goproxy/example.com/notags/@latest",
} {
_, loc, err := e.GoProxy(context.Background(), virt, path, "")
if err != nil || loc != want {
t.Errorf("%s -> %q %v, want %q", path, loc, err, want)
}
}
for _, path := range []string{
"example.unkin.net/!hello/@v/v9.9.9.zip",
"example.unkin.net/Hello/@v/list",
"example.unkin.net/!hello/@v/v0.1.0.tar",
"example.unkin.net/!hello/@v/../v0.1.0.zip",
"example.unkin.net/!hello",
"missing.example/mod/@v/list",
"missing.example/mod/@latest",
} {
if _, _, err := e.GoProxy(context.Background(), virt, path, ""); !errors.Is(err, ErrNotFound) {
t.Errorf("%s: want ErrNotFound, got %v", path, err)
}
}
if n := asked["internal example.unkin.net/!hello/@v/list"]; n != 1 {
t.Fatalf("merged list must be cached, internal asked %d times", n)
}
if n := asked["goproxy missing.example/mod/@v/list"]; n != 1 {
t.Fatalf("a module no member has must be cached, goproxy asked %d times", n)
}
}
func TestGoProxyFollowsOwnerChange(t *testing.T) {
docs := map[string]string{"internal example.com/mod/@v/list": "v0.1.0\n"}
e, _ := goEngine(docs, "")
e.mergeTTL = 0
zip := func() string {
t.Helper()
_, loc, err := e.GoProxy(context.Background(), goVirt(), "example.com/mod/@v/v0.1.0.zip", "")
if err != nil {
t.Fatal(err)
}
return loc
}
if loc := zip(); loc != "/api/v1/local/internal/example.com/mod/@v/v0.1.0.zip" {
t.Fatalf("before move: %q", loc)
}
delete(docs, "internal example.com/mod/@v/list")
docs["goproxy example.com/mod/@v/list"] = "v0.1.0\n"
if loc := zip(); loc != "/api/v1/remote/goproxy/example.com/mod/@v/v0.1.0.zip" {
t.Fatalf("same list, new owner: redirect still %q", loc)
}
}
func TestGoProxyFailsClosed(t *testing.T) {
e, _ := goEngine(map[string]string{"internal example.unkin.net/hello/@v/list": "v0.1.0\n"}, "goproxy")
for _, path := range []string{"example.unkin.net/hello/@v/list", "example.unkin.net/hello/@latest", "example.unkin.net/hello/@v/v0.1.0.zip"} {
if _, _, err := e.GoProxy(context.Background(), goVirt(), path, ""); err == nil || errors.Is(err, ErrNotFound) {
t.Errorf("%s: want member error, got %v", path, err)
}
}
}
func TestGoProxyLocalMemberNotFound(t *testing.T) {
ctx := context.Background()
dsn, terminate, err := testsupport.StartPostgres(ctx)
if err != nil {
t.Skip("Docker unavailable")
}
defer terminate()
db, err := database.New(dsn)
if err != nil {
t.Fatal(err)
}
defer db.Close()
local := models.Remote{Name: "internal", PackageType: models.PackageGoProxy, RepoType: models.RepoTypeLocal}
if err := db.CreateRemote(ctx, &local); err != nil {
t.Fatal(err)
}
e := NewEngine(db, nil)
if _, err := e.fetchMemberPath(ctx, local, "example.com/mod/@latest"); !errors.Is(err, ErrNotFound) {
t.Fatalf("local 404 must be ErrNotFound, got %v", err)
}
virt := models.Virtual{Name: "go", PackageType: models.PackageGoProxy, Members: []string{"internal"}}
if _, _, err := e.GoProxy(ctx, virt, "example.com/mod/@latest", ""); !errors.Is(err, ErrNotFound) {
t.Fatalf("module only absent from a local member must be not-found, got %v", err)
}
}
@@ -0,0 +1,12 @@
-- Sync state is dropped with its remote, so a recreated remote of the same name
-- starts with no inherited ETag or timing. NOT VALID skips checking rows left by
-- remotes deleted before this migration, which would otherwise fail it.
ALTER TABLE github_rpm_sync_state DROP CONSTRAINT IF EXISTS github_rpm_sync_state_remote_fk;
ALTER TABLE github_rpm_sync_state ADD CONSTRAINT github_rpm_sync_state_remote_fk
FOREIGN KEY (remote_name) REFERENCES remotes(name) ON DELETE CASCADE NOT VALID;
ALTER TABLE github_deb_sync_state DROP CONSTRAINT IF EXISTS github_deb_sync_state_remote_fk;
ALTER TABLE github_deb_sync_state ADD CONSTRAINT github_deb_sync_state_remote_fk
FOREIGN KEY (remote_name) REFERENCES remotes(name) ON DELETE CASCADE NOT VALID;
ALTER TABLE github_alpine_sync_state DROP CONSTRAINT IF EXISTS github_alpine_sync_state_remote_fk;
ALTER TABLE github_alpine_sync_state ADD CONSTRAINT github_alpine_sync_state_remote_fk
FOREIGN KEY (remote_name) REFERENCES remotes(name) ON DELETE CASCADE NOT VALID;
+5
View File
@@ -0,0 +1,5 @@
package models
const PackageAnsible PackageType = "ansible"
func init() { registerPackageType(PackageAnsible) }
+28
View File
@@ -0,0 +1,28 @@
import type { PackageTypeUI } from '../types';
const def: PackageTypeUI = {
color: 'red',
snippets: ({ name, url, proxy, isLocal }) => {
if (isLocal) {
const src = `${url}/api/v1/local/${name}/`;
return [
{
title: 'Publish and install',
language: 'bash',
code: `ansible-galaxy collection publish <ns>-<name>-<version>.tar.gz --server ${src} --api-key unused
ansible-galaxy collection install <ns>.<name> --server ${src}`,
note: 'publish needs an API key; any value works. Publishing an existing version is rejected.',
},
];
}
return [
{
title: 'Install collections through this Galaxy proxy',
language: 'bash',
code: `ansible-galaxy collection install community.general -s ${proxy}/api/`,
},
];
},
};
export default def;
+25 -1
View File
@@ -2,7 +2,31 @@ import type { PackageTypeUI } from '../types';
const def: PackageTypeUI = {
color: 'green',
snippets: ({ proxy }) => {
snippets: ({ name, url, proxy, isLocal }) => {
if (isLocal) {
return [
{
title: 'Fetch private modules from this proxy',
language: 'bash',
code: `export GOPROXY=${url}/api/v1/local/${name},https://proxy.golang.org,direct
export GONOSUMDB=example.unkin.net
go get example.unkin.net/hello@latest`,
note: 'Set GONOSUMDB (or GOPRIVATE) to your module prefix: private modules are not in sum.golang.org.',
},
{
title: 'Publish a module version',
language: 'bash',
code: `# build the zip with golang.org/x/mod/zip (CreateFromDir), or reuse
# the .zip/.mod/.info from $(go env GOMODCACHE)/cache/download after go mod download
MOD=example.unkin.net/hello VER=v0.1.0
for ext in zip mod info; do
curl -fsSL --upload-file $VER.$ext \\
${url}/api/v2/remotes/${name}/files/$MOD/@v/$VER.$ext
done`,
note: 'Paths use the case-encoded module path (uppercase as !lower). Zips must hold every file under module@version/ and are rejected otherwise; .info is optional.',
},
];
}
return [
{
title: 'Point the Go module proxy here',