Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e59d9728b2 | |||
| e4833b6f96 | |||
| 7f65fc377c | |||
| 00edeb478b | |||
| f5d30cc96e | |||
| a4a4fe57c9 | |||
| 5f33abba93 | |||
| a75aef8ded | |||
| f1b78988e4 | |||
| ee55ef3fcd | |||
| e5313342ba | |||
| f0ee593164 | |||
| 6cf84c4f6c | |||
| cfb1f93d48 | |||
| 9294686ea6 | |||
| fe9de81afa | |||
| 2905ec99e4 | |||
| 7fa5434dbc | |||
| ca269c5046 | |||
| 3da8545464 | |||
| 4649878a6d | |||
| 29e4777eec | |||
| 0cfde41612 | |||
| a00e45c3a7 | |||
| ca08c580b8 | |||
| ab723147c9 |
@@ -23,23 +23,26 @@ API: `http://localhost:8000` | Frontend: `http://localhost:5173`
|
||||
Providers classify paths automatically. Users only configure what to proxy and TTLs.
|
||||
|
||||
Classification precedence, TTLs and eviction for every remote: [docs/common/caching.md](docs/common/caching.md).
|
||||
Write authentication and per-client token usage: [docs/common/auth.md](docs/common/auth.md).
|
||||
|
||||
Each type's paths, Terraform examples and local/virtual behaviour are in `docs/<type>/`:
|
||||
|
||||
- `alpine`: [remote](docs/alpine/remote.md), [remote-github](docs/alpine/remote-github.md)
|
||||
- `alpine`: [remote](docs/alpine/remote.md), [remote-github](docs/alpine/remote-github.md), [virtual](docs/alpine/virtual.md)
|
||||
- `ansible`: [remote](docs/ansible/remote.md), [local](docs/ansible/local.md), [virtual](docs/ansible/virtual.md)
|
||||
- `cargo`: [remote](docs/cargo/remote.md)
|
||||
- `deb`: [remote](docs/deb/remote.md), [remote-github](docs/deb/remote-github.md)
|
||||
- `deb`: [remote](docs/deb/remote.md), [remote-github](docs/deb/remote-github.md), [virtual](docs/deb/virtual.md)
|
||||
- `docker`: [remote](docs/docker/remote.md), [local](docs/docker/local.md)
|
||||
- `generic`: [remote](docs/generic/remote.md)
|
||||
- `gems`: [remote](docs/gems/remote.md), [local](docs/gems/local.md), [virtual](docs/gems/virtual.md)
|
||||
- `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)
|
||||
- `puppet`: [remote](docs/puppet/remote.md), [local](docs/puppet/local.md)
|
||||
- `puppet`: [remote](docs/puppet/remote.md), [local](docs/puppet/local.md), [virtual](docs/puppet/virtual.md)
|
||||
- `pypi`: [remote](docs/pypi/remote.md)
|
||||
- `rpm`: [remote](docs/rpm/remote.md), [remote-github](docs/rpm/remote-github.md)
|
||||
- `terraform`: [remote](docs/terraform/remote.md), [local](docs/terraform/local.md)
|
||||
- `vcs`: [remote](docs/vcs/remote.md)
|
||||
|
||||
### GitHub authentication
|
||||
|
||||
@@ -200,14 +203,15 @@ additive and idempotent for the same reason; a test enforces it.
|
||||
| `GITHUB_SYNC_RATE` | `1` | `github_rpm` syncer global GitHub request rate (req/s), shared across all remotes. `1`/s = 3600/hr, under an authenticated token's ~5000/hr; unauthenticated (~60/hr) relies on ETag/304 |
|
||||
| `GITHUB_SYNC_BURST` | `5` | Token-bucket burst for the shared limiter |
|
||||
| `GITHUB_SYNC_WORKERS` | `3` | Concurrent `github_rpm` scan workers |
|
||||
| `GITHUB_SYNC_POLL_INTERVAL` | `60` | Base scheduler tick in seconds; per-remote cadence is its `mutable_ttl`, enforced by the DB lease |
|
||||
| `GITHUB_SYNC_POLL_INTERVAL` | `60` | Base scheduler tick in seconds; per-remote cadence is its `sync_interval` (phase-spread), else `mutable_ttl`, enforced by the DB lease |
|
||||
| `GITHUB_TOKEN` | | Server-level GitHub PAT (fine-grained or classic), sent as `Authorization: Bearer`. Applies to every GitHub request; per-remote creds override it. See [GitHub authentication](#github-authentication) |
|
||||
| `GITHUB_APP_ID` | | GitHub App id (App auth mode; mutually exclusive with `GITHUB_TOKEN`) |
|
||||
| `GITHUB_APP_INSTALLATION_ID` | | GitHub App installation id |
|
||||
| `GITHUB_APP_PRIVATE_KEY` | | GitHub App private key, inline PEM |
|
||||
| `GITHUB_APP_PRIVATE_KEY_PATH` | | GitHub App private key, file path (alternative to inline PEM) |
|
||||
| `ARTIFACTAPI_OIDC_ISSUER` | | OIDC issuer URL (e.g. Vault `https://vault.service.consul:8200/v1/identity/oidc`). When set, `POST /api/v2/remotes`, `POST /api/v2/virtuals` and `PUT`/`DELETE` on `/api/v2/{remotes,virtuals}/{name}` require `Authorization: Bearer <jwt>` verified against the issuer's JWKS. Uploads, object/file deletes, probe, reads and `/v2` stay open. Unset = no auth |
|
||||
| `ARTIFACTAPI_OIDC_ISSUER` | | OIDC issuer URL (e.g. Vault `https://vault.example.com/v1/identity/oidc`). When set, `POST /api/v2/remotes`, `POST /api/v2/virtuals` and `PUT`/`DELETE` on `/api/v2/{remotes,virtuals}/{name}` require a token verified against the issuer's JWKS. Unset = no auth. See [docs/common/auth.md](docs/common/auth.md) |
|
||||
| `ARTIFACTAPI_OIDC_AUDIENCE` | | Comma-separated allowed `aud` values (e.g. the Vault OIDC role's client_id). Required when `ARTIFACTAPI_OIDC_ISSUER` is set |
|
||||
| `ARTIFACTAPI_AUTH_UPLOADS` | `false` | Also require a token for every local repo write: file uploads/deletes, object evictions, `/api/v1/local` publishes and `/v2` push. Requires `ARTIFACTAPI_OIDC_ISSUER` |
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
@@ -5,3 +5,16 @@ Shared classification, TTL and eviction rules: [caching](../common/caching.md).
|
||||
| Type | Mutable (auto-detected) | Immutable (auto-detected) |
|
||||
|---|---|---|
|
||||
| `github_alpine` | `APKINDEX.tar.gz` (synthesized) | `.apk` (redirected) |
|
||||
|
||||
## Release sync
|
||||
|
||||
A background syncer re-scans the GitHub releases list per remote, sharing one
|
||||
lease per remote across replicas through `github_alpine_sync_state`.
|
||||
|
||||
- **`sync_interval`** (seconds) re-checks once per interval at a stable
|
||||
per-remote offset, so remotes on the same interval spread out instead of
|
||||
syncing together; its first scheduled sync waits for that slot (a cold-cache
|
||||
request still primes at once). Unset, it re-checks no more often than its
|
||||
`mutable_ttl`.
|
||||
- A failed scan keeps serving the last good index and retries with exponential
|
||||
backoff.
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# `alpine` virtual
|
||||
|
||||
An alpine virtual serves one apk repository over local and remote alpine
|
||||
members at `/api/v1/virtual/<name>/`.
|
||||
|
||||
- `<dir>/APKINDEX.tar.gz` merges each member's index for the same `<dir>`
|
||||
(e.g. `x86_64/`); on a duplicate (P, V, A) the earlier member wins. A member
|
||||
that 404s for that directory is skipped.
|
||||
- apk builds download paths itself (`<dir>/<P>-<V>.apk`), so the virtual 302s
|
||||
each download to the member whose index listed it.
|
||||
- The merged index is unsigned, like local and `github_alpine` indexes: clients
|
||||
need `--allow-untrusted` (packages keep their own signatures).
|
||||
- Any other member error fails the request with 502. Merges are cached for 60s
|
||||
per directory, up to 32 directories per virtual.
|
||||
|
||||
```hcl
|
||||
resource "artifactapi_virtual" "alpine" {
|
||||
name = "alpine"
|
||||
package_type = "alpine"
|
||||
members = [artifactapi_local_alpine.internal.name, artifactapi_remote_github_alpine.tool.name]
|
||||
}
|
||||
```
|
||||
|
||||
```sh
|
||||
echo https://artifactapi.example.com/api/v1/virtual/alpine >> /etc/apk/repositories
|
||||
apk update --allow-untrusted
|
||||
apk add --allow-untrusted mytool
|
||||
```
|
||||
|
||||
Upload to the local member directly; the virtual is read-only.
|
||||
@@ -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 unless [uploads are gated](../common/auth.md).
|
||||
|
||||
```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.
|
||||
@@ -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/
|
||||
```
|
||||
@@ -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.
|
||||
@@ -0,0 +1,53 @@
|
||||
# Write authentication
|
||||
|
||||
artifactapi verifies writes against an OIDC issuer's JWKS, such as a Vault
|
||||
identity OIDC role. Reads, probes and anonymous pulls never need a token.
|
||||
|
||||
| Variable | Gates |
|
||||
|---|---|
|
||||
| `ARTIFACTAPI_OIDC_ISSUER` + `ARTIFACTAPI_OIDC_AUDIENCE` | creating, updating and deleting remotes and virtuals |
|
||||
| `ARTIFACTAPI_AUTH_UPLOADS=true` | also every local repo write: `PUT`/`DELETE /api/v2/remotes/<name>/files/*`, `DELETE /api/v2/{remotes,locals}/<name>/objects/*`, `PUT`/`POST`/`DELETE /api/v1/local/<repo>/*` (npm publish, gem push/yank, ansible-galaxy publish) and Docker Registry V2 push/delete under `/v2` |
|
||||
|
||||
Any token from the issuer whose `aud` is in `ARTIFACTAPI_OIDC_AUDIENCE` may
|
||||
write to every repo.
|
||||
|
||||
## Minting a token
|
||||
|
||||
```sh
|
||||
vault read -field=token identity/oidc/token/<role> > token
|
||||
```
|
||||
|
||||
The role's `client_id` must be one of the configured audiences.
|
||||
|
||||
## Passing the token
|
||||
|
||||
The token is accepted as `Authorization: Bearer <token>`, as `Token <token>`,
|
||||
as the password of HTTP Basic credentials with any username, or as a bare
|
||||
header value.
|
||||
|
||||
```sh
|
||||
# curl
|
||||
curl -fsSL -H "Authorization: Bearer $(cat token)" --upload-file f.tgz \
|
||||
https://artifactapi.example.com/api/v2/remotes/<repo>/files/f.tgz
|
||||
curl -fsSL -u "ci:$(cat token)" --upload-file f.tgz \
|
||||
https://artifactapi.example.com/api/v2/remotes/<repo>/files/f.tgz
|
||||
|
||||
# docker / podman / helm (OCI): any username, token as password
|
||||
docker login artifactapi.example.com -u ci --password-stdin < token
|
||||
helm registry login artifactapi.example.com -u ci --password-stdin < token
|
||||
|
||||
# npm (.npmrc)
|
||||
//artifactapi.example.com/api/v1/local/<repo>/:_authToken=<token>
|
||||
|
||||
# gem
|
||||
GEM_HOST_API_KEY="$(cat token)" gem push --host https://artifactapi.example.com/api/v1/local/<repo> mygem-1.0.0.gem
|
||||
|
||||
# ansible-galaxy
|
||||
ansible-galaxy collection publish c.tar.gz \
|
||||
--server https://artifactapi.example.com/api/v1/local/<repo>/ --api-key "$(cat token)"
|
||||
```
|
||||
|
||||
With uploads gated, `/v2/` answers anonymous requests with a Bearer challenge
|
||||
pointing at `/v2/token`. That endpoint returns the verified Basic password as
|
||||
the registry token, or an anonymous token that only reads, so `docker pull`
|
||||
keeps working without a login.
|
||||
+23
-3
@@ -13,9 +13,11 @@ Every proxied request on a remote is classified as **denied**, **immutable** or
|
||||
| `mutable_patterns` | Regexes forcing a path mutable. |
|
||||
| `immutable_ttl` | Seconds an immutable path stays fresh. `0` = forever. |
|
||||
| `mutable_ttl` | Seconds a mutable path stays fresh. `0` also means never expires. Omitted on create, it defaults to `3600`; omitted on update, the stored value is kept. |
|
||||
| `sync_interval` | github_rpm/github_deb/github_alpine only. Seconds between background release re-scans, at a stable per-remote offset. Unset (`0`) re-scans no more often than `mutable_ttl`. |
|
||||
| `check_mutable` | When a mutable path's `mutable_ttl` expires, sends a conditional `HEAD` (`If-None-Match`) with the stored upstream ETag. A `304` re-arms the TTL and serves the stored copy; anything else refetches in full. The ETag is kept for `max(10 × mutable_ttl, 7 days)`, re-armed on each `304`, so idle paths age out. It is only stored when `check_mutable` is set and `mutable_ttl` is non-zero, and never for immutable paths. |
|
||||
| `stale_on_error` | On an upstream network error, serve the stored copy instead of failing. |
|
||||
| `quarantine_enabled` / `quarantine_days` | Stored on the remote but not enforced by the proxy yet. |
|
||||
| `serve_stale` | rpm/deb/alpine virtual members only. When a virtual re-merges, use this member's previous index at once and refresh it in the background; the next merge picks up the refresh. The first merge still waits, and so does any merge once the previous index is over an hour old, so a member that keeps failing surfaces its error. Default `false`. |
|
||||
| `quarantine_enabled` / `quarantine_days` | Refuses immutable artifacts published less than `quarantine_days` (default `3`) ago. See [Quarantine](#quarantine). |
|
||||
| `mirrorlist` / `mirror_strategy` | rpm/deb/alpine only. Upstream pool is `[base_url] + mirrorlist`; `round_robin` (default) rotates the starting mirror, `least_conn` starts at the one with the fewest in-flight requests. Network errors and 5xx fail over to the next mirror; other statuses return as-is. |
|
||||
|
||||
## Precedence
|
||||
@@ -52,6 +54,24 @@ So `blocklist` beats the allowlist, and `immutable_patterns` beats
|
||||
Concurrent misses on one path share a fetch lock. Waiters poll the store for
|
||||
up to 5s instead of all going upstream.
|
||||
|
||||
## Quarantine
|
||||
|
||||
With `quarantine_enabled`, an uncached **immutable** path whose upstream copy
|
||||
was published less than `quarantine_days` ago answers `403` and is not cached:
|
||||
|
||||
```json
|
||||
{"error": "quarantined: published 2026-10-24T09:00:00Z, available from 2026-10-27T09:00:00Z",
|
||||
"published": "2026-10-24T09:00:00Z", "available": "2026-10-27T09:00:00Z"}
|
||||
```
|
||||
|
||||
- `quarantine_days` must be at least `1` when quarantine is enabled.
|
||||
- The publish time is the upstream `Last-Modified` header (capped at now), else the first time artifactapi was asked for the path.
|
||||
- If that first-seen lookup fails, the path answers `503` with `Retry-After` instead.
|
||||
- Mutable paths (indexes, metadata) are never quarantined.
|
||||
- Paths already cached keep serving, including ones cached before quarantine was enabled.
|
||||
- A virtual redirects to the member that owns the path, so its `403` reaches the client. A goproxy virtual probing for an unlisted version skips a quarantining member and only redirects to it when no other member serves the version.
|
||||
- Indexes still list fresh versions, so a client resolving "latest" may pick one and get the `403`.
|
||||
|
||||
## Eviction
|
||||
|
||||
```sh
|
||||
@@ -93,9 +113,9 @@ 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),
|
||||
[puppet](../puppet/remote.md), [pypi](../pypi/remote.md), [rpm](../rpm/remote.md),
|
||||
[rpm github](../rpm/remote-github.md), [terraform](../terraform/remote.md).
|
||||
[rpm github](../rpm/remote-github.md), [terraform](../terraform/remote.md), [vcs](../vcs/remote.md).
|
||||
|
||||
@@ -5,3 +5,16 @@ Shared classification, TTL and eviction rules: [caching](../common/caching.md).
|
||||
| Type | Mutable (auto-detected) | Immutable (auto-detected) |
|
||||
|---|---|---|
|
||||
| `github_deb` | `Packages*`, `Release*` (synthesized) | `.deb` (redirected) |
|
||||
|
||||
## Release sync
|
||||
|
||||
A background syncer re-scans the GitHub releases list per remote, sharing one
|
||||
lease per remote across replicas through `github_deb_sync_state`.
|
||||
|
||||
- **`sync_interval`** (seconds) re-checks once per interval at a stable
|
||||
per-remote offset, so remotes on the same interval spread out instead of
|
||||
syncing together; its first scheduled sync waits for that slot (a cold-cache
|
||||
request still primes at once). Unset, it re-checks no more often than its
|
||||
`mutable_ttl`.
|
||||
- A failed scan keeps serving the last good index and retries with exponential
|
||||
backoff.
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# `deb` virtual
|
||||
|
||||
A deb virtual serves one apt source over local and remote deb members at
|
||||
`/api/v1/virtual/<name>/`, in either the flat (`./`) or `dists/<suite>` layout.
|
||||
|
||||
- Packages indexes are merged; on a duplicate (Package, Version, Architecture)
|
||||
the earlier member wins.
|
||||
- The Release is generated unsigned with `Acquire-By-Hash: yes`; indexes are
|
||||
served by name and by SHA256 from the current and previous merge.
|
||||
- `Filename` points at the virtual (`<member>/<path>`), which 302s to the
|
||||
owning member.
|
||||
- All members must share one layout; mixing flat and suite members is
|
||||
unsupported (suite requests fail with 502).
|
||||
- A member error fails the request with 502. Merges are cached for 60s per
|
||||
suite, up to 32 suites per virtual.
|
||||
|
||||
```hcl
|
||||
resource "artifactapi_virtual" "deb" {
|
||||
name = "deb"
|
||||
package_type = "deb"
|
||||
members = [artifactapi_local_deb.internal.name, artifactapi_remote_github_deb.tool.name]
|
||||
}
|
||||
```
|
||||
|
||||
```
|
||||
# /etc/apt/sources.list.d/artifactapi.list (flat)
|
||||
deb [trusted=yes] https://artifactapi.example.com/api/v1/virtual/deb/ ./
|
||||
# suite layout
|
||||
deb [trusted=yes] https://artifactapi.example.com/api/v1/virtual/debian/ trixie main
|
||||
```
|
||||
|
||||
Upload to the local member directly; the virtual is read-only.
|
||||
@@ -10,6 +10,9 @@ docker push artifactapi.example.com/docker-internal/myapp:latest
|
||||
docker pull artifactapi.example.com/docker-internal/myapp:latest
|
||||
```
|
||||
|
||||
With [uploads gated](../common/auth.md), `docker login` with any username and
|
||||
a token as the password before pushing; pulls stay anonymous.
|
||||
|
||||
The first path segment after `/v2/` is the artifactapi repo name; the remainder
|
||||
is the image name. Blobs and manifests are stored through the shared
|
||||
content-addressable store (deduplicated by digest, reaped by GC once
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
A local `gems` repo is a RubyGems source: `gem push`, `gem yank`, `gem install`
|
||||
and `bundle install` work against `/api/v1/local/<repo>`. `gem push` needs an
|
||||
API key; any value works.
|
||||
API key; any value works unless [uploads are gated](../common/auth.md).
|
||||
|
||||
```bash
|
||||
GEM_HOST_API_KEY=unused gem push --host https://artifactapi.example.com/api/v1/local/<repo> mygem-1.0.0.gem
|
||||
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
+2
-1
@@ -2,7 +2,8 @@
|
||||
|
||||
A local `npm` repo is a real npm registry: `npm publish` (scoped and unscoped)
|
||||
and `npm install` work against `/api/v1/local/<repo>/`. npm requires an
|
||||
`_authToken` for the registry before it will publish; any value works.
|
||||
`_authToken` for the registry before it will publish; any value works unless
|
||||
[uploads are gated](../common/auth.md).
|
||||
|
||||
```ini
|
||||
# .npmrc
|
||||
|
||||
@@ -5,3 +5,6 @@ Shared classification, TTL and eviction rules: [caching](../common/caching.md).
|
||||
| Type | Mutable (auto-detected) | Immutable (auto-detected) |
|
||||
|---|---|---|
|
||||
| `puppet` | `v3/modules/*`, `v3/releases*` | `.tar.gz` |
|
||||
|
||||
`/v3/releases` forwards the `module`, `limit` and `offset` query params
|
||||
upstream; set `base_url` to the Forge API root, e.g. `https://forgeapi.puppet.com`.
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# `puppet` virtual
|
||||
|
||||
A puppet virtual serves one Forge v3 API over local and remote puppet members:
|
||||
`puppet module install` and r10k work against `/api/v1/virtual/<name>`, with
|
||||
dependencies resolved across members.
|
||||
|
||||
- `/v3/releases?module=<slug>` merges member releases; on a duplicate version
|
||||
the earlier member wins. Releases are newest first, paginated with
|
||||
`limit` (up to 100) and `offset`; pagination links and `file_uri` are
|
||||
relative to the Forge root, so downloads route via the virtual.
|
||||
- `/v3/modules/<slug>` lists the merged releases with the highest version as
|
||||
`current_release`.
|
||||
- `/v3/releases/<slug>-<version>` and `/v3/files/<file>` 302 to the member
|
||||
owning that release.
|
||||
- A member error fails the request with 502; a member without the module is
|
||||
skipped. Merges, and modules no member has, are cached for 60s.
|
||||
|
||||
```hcl
|
||||
resource "artifactapi_virtual" "puppet" {
|
||||
name = "puppet"
|
||||
package_type = "puppet"
|
||||
members = [artifactapi_local_puppet.internal.name, artifactapi_remote_puppet.forge.name]
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
puppet module install unkin-hello \
|
||||
--module_repository https://artifactapi.example.com/api/v1/virtual/puppet
|
||||
```
|
||||
|
||||
```yaml
|
||||
# r10k.yaml
|
||||
forge:
|
||||
baseurl: https://artifactapi.example.com/api/v1/virtual/puppet
|
||||
```
|
||||
|
||||
Upload to the local member directly; the virtual is read-only.
|
||||
@@ -32,10 +32,14 @@ derived metadata current off the client request path:
|
||||
The first `dnf` request is served from cache. If a request arrives before the
|
||||
prime lands, it returns a retryable `503` (with `Retry-After`) rather than
|
||||
serving an empty repo or blocking on a multi-minute derive.
|
||||
- **Periodic re-check, driven by `mutable_ttl`.** Each remote is re-checked for
|
||||
new or changed releases no more often than its `mutable_ttl`. New/changed
|
||||
assets are derived incrementally; assets already cached are never re-fetched,
|
||||
and assets that disappear upstream are pruned.
|
||||
- **Periodic re-check, driven by `sync_interval` or `mutable_ttl`.** With
|
||||
`sync_interval` (seconds) set, a remote re-checks once per interval at a
|
||||
stable per-remote offset, so remotes on the same interval spread out instead
|
||||
of syncing together; its first scheduled sync waits for that slot (a
|
||||
cold-cache request still primes at once). Unset, it re-checks no more often
|
||||
than its `mutable_ttl`. New/changed assets are derived incrementally; assets
|
||||
already cached are never re-fetched, and assets that disappear upstream are
|
||||
pruned.
|
||||
- **ETag / 304 conditional requests.** The releases-list `ETag` is stored per
|
||||
remote and sent as `If-None-Match`; a `304 Not Modified` means nothing changed
|
||||
and the syncer derives nothing. GitHub does not count `304` conditional
|
||||
@@ -50,8 +54,8 @@ derived metadata current off the client request path:
|
||||
periodic scan a replica must atomically claim a per-remote lease
|
||||
(`github_rpm_sync_state`: `last_synced_at`, `etag`, `sync_lease_owner`,
|
||||
`sync_lease_expires`); only the winner scans. This bounds total GitHub load to
|
||||
~once per `mutable_ttl` regardless of replica count, and the shared `etag`
|
||||
lets any replica issue the conditional request.
|
||||
~once per `sync_interval` (else `mutable_ttl`) regardless of replica count,
|
||||
and the shared `etag` lets any replica issue the conditional request.
|
||||
|
||||
```hcl
|
||||
# Backend that serves the actual .rpm bytes from github.com.
|
||||
@@ -68,6 +72,7 @@ resource "artifactapi_remote_github_rpm" "acme-tools" {
|
||||
base_url = "https://api.github.com/repos/acme/tools" # the releases API root
|
||||
releases_remote = "github" # backend for downloads
|
||||
mutable_ttl = 3600 # release re-scan interval
|
||||
cache_repodata = true # keep generated repodata in memory
|
||||
|
||||
# Optional: restrict which release assets become packages (regex on filename).
|
||||
patterns = [".*\\.x86_64\\.rpm$", ".*\\.noarch\\.rpm$"]
|
||||
@@ -80,3 +85,13 @@ resource "artifactapi_remote_github_rpm" "acme-tools" {
|
||||
`dnf` config: `baseurl=https://artifactapi.example/api/v1/remote/acme-tools`.
|
||||
The repo is multi-arch (no `$basearch` needed) — `dnf` selects matching packages
|
||||
from the synthesized metadata.
|
||||
|
||||
### Repodata cache
|
||||
|
||||
Repodata is regenerated from the metadata rows on every request by default. Set
|
||||
`cache_repodata = true` on the remote to keep the generated `repomd.xml` and its
|
||||
`primary`/`filelists`/`other` files in memory per replica. Each request checks a
|
||||
cheap row fingerprint (count + newest id) in the database and rebuilds only when
|
||||
it moved, so every replica picks up a scan's changes on its next request. The
|
||||
fingerprint relies on only one scan writing a remote's rows at a time; in the
|
||||
deployed server, the sync lease serialises scans across replicas.
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# `vcs` remote
|
||||
|
||||
Shared classification, TTL and eviction rules: [caching](../common/caching.md).
|
||||
|
||||
Caches source archives and release assets from GitHub (`base_url = "https://github.com"`) or Gitea (any other `base_url`). GitHub Enterprise is not supported: a non-github.com host is treated as Gitea. Only the paths below are served; anything else is a 404.
|
||||
|
||||
| Path | Behaviour |
|
||||
|---|---|
|
||||
| `<owner>/<repo>/archive/refs/tags/<tag>.(tar.gz\|zip)` | immutable |
|
||||
| `<owner>/<repo>/archive/<40-char sha>.(tar.gz\|zip)` | immutable |
|
||||
| `<owner>/<repo>/releases/download/<tag>/<asset>` | immutable |
|
||||
| `<owner>/<repo>/releases/latest/download/<asset>` | mutable |
|
||||
| `<owner>/<repo>/archive/refs/heads/<branch>.(tar.gz\|zip)` | `302` to the archive of the branch's current commit, resolved via the provider's branch API and reused for `mutable_ttl` |
|
||||
|
||||
Branch archives redirect, so `curl` needs `-L`; pip and npm follow redirects. The archive is the host's commit archive (GitHub names its top-level directory `<repo>-<sha>`). `patterns` and `blocklist` match both the branch path and the commit path it redirects to, so allowlist repos by prefix (`^cli/cli/`); a branch whose commit path is denied is a `403`. A missing branch is a `404`, cached for `mutable_ttl` (at least 60s). An API rate limit (`429`, or `403` with rate-limit headers) is a `503` with `Retry-After`, unless `stale_on_error` serves the last known commit. Each remote caches up to 4096 branch lookups. GitHub API calls use the [server GitHub credential](../../README.md#github-authentication); Gitea API calls use the remote's `username`/`password`.
|
||||
|
||||
```hcl
|
||||
resource "artifactapi_remote_vcs" "github" {
|
||||
name = "github-vcs"
|
||||
base_url = "https://github.com"
|
||||
mutable_ttl = 600
|
||||
patterns = ["^cli/cli/", "^psf/requests/"]
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
curl -fsSLO https://artifactapi.example/api/v1/remote/github-vcs/cli/cli/archive/refs/tags/v2.60.0.tar.gz
|
||||
pip install https://artifactapi.example/api/v1/remote/github-vcs/psf/requests/archive/refs/tags/v2.32.3.tar.gz
|
||||
```
|
||||
@@ -31,12 +31,18 @@ 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); puppet over two
|
||||
locals via real `puppet module install` (dependency resolved across members)
|
||||
and `r10k`, downloads 302 to the owning member.
|
||||
- **Mirrorlist** — an rpm remote with a `mirrorlist` of extra upstream mirrors
|
||||
(pool = `base_url` + `mirrorlist`): round-robin distribution across both mirrors
|
||||
(constant-body `mockupstreama` / `mockupstreamb`), failover past a dead primary,
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
//go:build dockere2e
|
||||
|
||||
package e2edocker
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// abuildScript builds a real, abuild-signed $N-$V-r0.apk and writes it to stdout.
|
||||
const abuildScript = `set -eu
|
||||
apk add -q abuild >&2
|
||||
adduser -D b && addgroup b abuild
|
||||
su b -c 'set -eu; abuild-keygen -an >&2; mkdir -p ~/p && cd ~/p
|
||||
printf "%s\n" "pkgname=$N" "pkgver=$V" pkgrel=0 "pkgdesc=\"e2e\"" "url=\"https://example.com\"" "arch=\"x86_64\"" "license=\"MIT\"" "options=\"!check\"" \
|
||||
"package() { mkdir -p \"\$pkgdir/usr/share/$N\"; echo ok > \"\$pkgdir/usr/share/$N/ok\"; }" > APKBUILD
|
||||
abuild -d >&2 || true'
|
||||
cat /home/b/packages/*/x86_64/$N-$V-r0.apk`
|
||||
|
||||
func buildApk(t *testing.T, name, version string) []byte {
|
||||
t.Helper()
|
||||
cmd := exec.Command("docker", "run", "--rm", "-e", "N="+name, "-e", "V="+version, "alpine:3.20", "sh", "-c", abuildScript)
|
||||
var stderr strings.Builder
|
||||
cmd.Stderr = &stderr
|
||||
out, err := cmd.Output()
|
||||
if err != nil || len(out) == 0 {
|
||||
t.Fatalf("abuild %s-%s: %v\n%s", name, version, err, stderr.String())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestVirtualAlpineMerge merges two local alpine repos carrying the same
|
||||
// abuild-built package: the first member wins the duplicate and its download,
|
||||
// and a real apk installs packages from both members through the virtual.
|
||||
func TestVirtualAlpineMerge(t *testing.T) {
|
||||
network := os.Getenv("COMPOSE_NETWORK")
|
||||
internal := os.Getenv("ARTIFACTAPI_INTERNAL")
|
||||
if network == "" || internal == "" {
|
||||
t.Skip("COMPOSE_NETWORK/ARTIFACTAPI_INTERNAL not set; skipping real apk")
|
||||
}
|
||||
if _, err := exec.LookPath("docker"); err != nil {
|
||||
t.Skip("docker not available on the test host")
|
||||
}
|
||||
one, two := buildApk(t, "vapk-one", "1.0"), buildApk(t, "vapk-two", "2.0")
|
||||
|
||||
createRepo(t, `{"name":"vapk-a","package_type":"alpine","repo_type":"local"}`)
|
||||
createRepo(t, `{"name":"vapk-b","package_type":"alpine","repo_type":"local"}`)
|
||||
defer deleteRepo(t, "vapk-a")
|
||||
defer deleteRepo(t, "vapk-b")
|
||||
|
||||
const apkType = "application/vnd.android.package-archive"
|
||||
uploadFile(t, "vapk-a", "x86_64/vapk-one-1.0-r0.apk", one, apkType)
|
||||
uploadFile(t, "vapk-b", "x86_64/vapk-one-1.0-r0.apk", one, apkType)
|
||||
uploadFile(t, "vapk-b", "x86_64/vapk-two-2.0-r0.apk", two, apkType)
|
||||
|
||||
// Local indexes list uploads once their metadata is parsed asynchronously,
|
||||
// and the virtual caches its merge, so merge only once both members list all.
|
||||
for _, m := range []struct{ repo, pkg string }{{"vapk-a", "P:vapk-one\n"}, {"vapk-b", "P:vapk-two\n"}} {
|
||||
var idx string
|
||||
for deadline := time.Now().Add(15 * time.Second); !strings.Contains(idx, m.pkg) && time.Now().Before(deadline); time.Sleep(250 * time.Millisecond) {
|
||||
_, body := doRequest(t, http.MethodGet, api("/api/v1/local/"+m.repo+"/x86_64/APKINDEX.tar.gz"), nil, "")
|
||||
idx = apkIndexText(t, body)
|
||||
}
|
||||
}
|
||||
|
||||
createVirtual(t, `{"name":"vapk","package_type":"alpine","members":["vapk-a","vapk-b"]}`)
|
||||
defer deleteVirtual(t, "vapk")
|
||||
|
||||
resp, body := doRequest(t, http.MethodGet, api("/api/v1/virtual/vapk/x86_64/APKINDEX.tar.gz"), nil, "")
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("virtual APKINDEX: status %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
index := apkIndexText(t, body)
|
||||
if strings.Count(index, "P:vapk-one\n") != 1 || !strings.Contains(index, "P:vapk-two\n") {
|
||||
t.Fatalf("merged APKINDEX should hold vapk-one once and vapk-two:\n%s", index)
|
||||
}
|
||||
|
||||
noRedirect := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
||||
for pkg, member := range map[string]string{"vapk-one-1.0-r0.apk": "vapk-a", "vapk-two-2.0-r0.apk": "vapk-b"} {
|
||||
resp, err := noRedirect.Get(api("/api/v1/virtual/vapk/x86_64/" + pkg))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if loc := resp.Header.Get("Location"); resp.StatusCode != http.StatusFound || !strings.HasSuffix(loc, "/api/v1/local/"+member+"/x86_64/"+pkg) {
|
||||
t.Fatalf("%s: want 302 to %s, got %d %q", pkg, member, resp.StatusCode, loc)
|
||||
}
|
||||
}
|
||||
|
||||
script := "set -eu; " +
|
||||
"echo \"$URL\" > /etc/apk/repositories; " +
|
||||
"apk update --allow-untrusted; " +
|
||||
"apk add --allow-untrusted vapk-one vapk-two; " +
|
||||
"test -f /usr/share/vapk-one/ok && test -f /usr/share/vapk-two/ok"
|
||||
out, err := exec.Command("docker", "run", "--rm", "--network", network,
|
||||
"-e", "URL="+strings.TrimRight(internal, "/")+"/api/v1/virtual/vapk",
|
||||
"alpine:3.20", "sh", "-c", script).CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("real apk against alpine virtual failed: %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build dockere2e
|
||||
|
||||
package e2edocker
|
||||
|
||||
func init() {
|
||||
cachingCases = append(cachingCases, cachingCase{"vcs", "octo/repo/archive/refs/tags/v1.0.0.tar.gz", "octo/repo/archive/refs/tags/v1.0.0.tar.gz"})
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
//go:build dockere2e
|
||||
|
||||
package e2edocker
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/testsupport"
|
||||
)
|
||||
|
||||
// TestVirtualDebMerge merges two local deb repos carrying the same package: the
|
||||
// first member wins the duplicate, and a real apt sees both members' packages
|
||||
// and installs through the virtual.
|
||||
func TestVirtualDebMerge(t *testing.T) {
|
||||
createRepo(t, `{"name":"vdeb-a","package_type":"deb","repo_type":"local"}`)
|
||||
createRepo(t, `{"name":"vdeb-b","package_type":"deb","repo_type":"local"}`)
|
||||
defer deleteRepo(t, "vdeb-a")
|
||||
defer deleteRepo(t, "vdeb-b")
|
||||
|
||||
uploadFile(t, "vdeb-a", "vdeb-one_1.0.0_amd64.deb", testsupport.MinimalDeb("vdeb-one", "1.0.0", "amd64"), "application/vnd.debian.binary-package")
|
||||
uploadFile(t, "vdeb-b", "vdeb-one_1.0.0_amd64.deb", testsupport.MinimalDeb("vdeb-one", "1.0.0", "amd64"), "application/vnd.debian.binary-package")
|
||||
uploadFile(t, "vdeb-b", "vdeb-two_2.0.0_amd64.deb", testsupport.MinimalDeb("vdeb-two", "2.0.0", "amd64"), "application/vnd.debian.binary-package")
|
||||
|
||||
// Local indexes regenerate asynchronously; merge only once vdeb-b lists both.
|
||||
deadline := time.Now().Add(15 * time.Second)
|
||||
for {
|
||||
resp, body := doRequest(t, http.MethodGet, api("/api/v1/local/vdeb-b/Packages"), nil, "")
|
||||
if resp.StatusCode == http.StatusOK && strings.Contains(string(body), "vdeb-two") || time.Now().After(deadline) {
|
||||
break
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
|
||||
createVirtual(t, `{"name":"vdeb","package_type":"deb","members":["vdeb-a","vdeb-b"]}`)
|
||||
defer deleteVirtual(t, "vdeb")
|
||||
|
||||
_, body := doRequest(t, http.MethodGet, api("/api/v1/virtual/vdeb/Packages"), nil, "")
|
||||
pk := string(body)
|
||||
if strings.Count(pk, "Package: vdeb-one\n") != 1 || !strings.Contains(pk, "Filename: vdeb-a/pool/vdeb-one_1.0.0_amd64.deb") ||
|
||||
!strings.Contains(pk, "Filename: vdeb-b/pool/vdeb-two_2.0.0_amd64.deb") {
|
||||
t.Fatalf("merged Packages should hold vdeb-one from the first member and vdeb-two:\n%s", pk)
|
||||
}
|
||||
|
||||
network := os.Getenv("COMPOSE_NETWORK")
|
||||
internal := os.Getenv("ARTIFACTAPI_INTERNAL")
|
||||
if network == "" || internal == "" {
|
||||
t.Skip("COMPOSE_NETWORK/ARTIFACTAPI_INTERNAL not set; skipping real apt")
|
||||
}
|
||||
if _, err := exec.LookPath("docker"); err != nil {
|
||||
t.Skip("docker not available on the test host")
|
||||
}
|
||||
script := "set -euo pipefail; " +
|
||||
"rm -f /etc/apt/sources.list.d/*; " +
|
||||
"echo \"deb [trusted=yes] $URL ./\" > /etc/apt/sources.list.d/vdeb.list; " +
|
||||
"apt-get update; " +
|
||||
"apt-cache policy vdeb-one vdeb-two | tee /dev/stderr | grep -c virtual/vdeb | grep -qx 2; " +
|
||||
"apt-get install -y vdeb-one vdeb-two; " +
|
||||
"dpkg -s vdeb-one vdeb-two"
|
||||
out, err := exec.Command("docker", "run", "--rm", "--network", network,
|
||||
"-e", "URL="+strings.TrimRight(internal, "/")+"/api/v1/virtual/vdeb/",
|
||||
"debian:13", "bash", "-c", script).CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("real apt against deb virtual failed: %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVirtualDebSuite merges two proxied deb.debian.org remotes and drives a
|
||||
// real apt through the virtual's dists/<suite> layout (by-hash indexes).
|
||||
func TestVirtualDebSuite(t *testing.T) {
|
||||
network := os.Getenv("COMPOSE_NETWORK")
|
||||
internal := os.Getenv("ARTIFACTAPI_INTERNAL")
|
||||
if network == "" || internal == "" {
|
||||
t.Skip("COMPOSE_NETWORK/ARTIFACTAPI_INTERNAL not set; skipping real apt")
|
||||
}
|
||||
if _, err := exec.LookPath("docker"); err != nil {
|
||||
t.Skip("docker not available on the test host")
|
||||
}
|
||||
for _, n := range []string{"vdebs-a", "vdebs-b"} {
|
||||
createRepo(t, `{"name":"`+n+`","package_type":"deb","repo_type":"remote","base_url":"http://deb.debian.org/debian"}`)
|
||||
defer deleteRepo(t, n)
|
||||
}
|
||||
createVirtual(t, `{"name":"vdebs","package_type":"deb","members":["vdebs-a","vdebs-b"]}`)
|
||||
defer deleteVirtual(t, "vdebs")
|
||||
|
||||
resp, rel := doRequest(t, http.MethodGet, api("/api/v1/virtual/vdebs/dists/trixie-updates/Release"), nil, "")
|
||||
if resp.StatusCode != http.StatusOK || !strings.Contains(string(rel), "Acquire-By-Hash: yes\n") ||
|
||||
!strings.Contains(string(rel), " main/binary-amd64/Packages.gz\n") {
|
||||
t.Fatalf("merged suite Release: status %d:\n%s", resp.StatusCode, rel)
|
||||
}
|
||||
|
||||
script := "set -euo pipefail; " +
|
||||
"rm -f /etc/apt/sources.list.d/*; " +
|
||||
"echo \"deb [trusted=yes] $URL trixie-updates main\" > /etc/apt/sources.list.d/vdebs.list; " +
|
||||
"apt-get update; " +
|
||||
"idx=/tmp/avail; apt-cache dumpavail > $idx; " +
|
||||
"grep -q '^Filename: vdebs-a/pool/' $idx; if grep -q '^Filename: vdebs-b/' $idx; then echo 'vdebs-b not deduped'; exit 1; fi; " +
|
||||
"pkg=$(awk '/^Package:/{p=$2} /^Size:/{if(!m||$2<m){m=$2;b=p}} END{print b}' $idx); " +
|
||||
"cd /tmp && apt-get download $pkg && ls -l /tmp/*.deb"
|
||||
out, err := exec.Command("docker", "run", "--rm", "--network", network,
|
||||
"-e", "URL="+strings.TrimRight(internal, "/")+"/api/v1/virtual/vdebs/",
|
||||
"debian:13", "bash", "-c", script).CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("real apt against deb virtual suite failed: %v\n%s", err, out)
|
||||
}
|
||||
t.Logf("%s", out)
|
||||
resp, _ = doRequest(t, http.MethodGet, api("/api/v1/remote/vdebs-a/dists/trixie-updates/main/binary-amd64/Packages.xz"), nil, "")
|
||||
if src := resp.Header.Get("X-Artifact-Source"); resp.StatusCode != http.StatusOK || src != "cache" {
|
||||
t.Fatalf("member index the virtual merged should be cached: status %d source %q", resp.StatusCode, src)
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
-24
@@ -150,31 +150,15 @@ func TestLocalAlpineIndex(t *testing.T) {
|
||||
apk := testsupport.MinimalApk("e2e-testpkg", "1.0-r0", "x86_64")
|
||||
uploadFile(t, "local-alpine", "x86_64/e2e-testpkg-1.0-r0.apk", apk, "application/vnd.android.package-archive")
|
||||
|
||||
// The index is generated asynchronously after upload; poll for it.
|
||||
resp, body := getEventually(t, api("/api/v1/local/local-alpine/x86_64/APKINDEX.tar.gz"), 15*time.Second)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("APKINDEX: status %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
zr, err := gzip.NewReader(bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("APKINDEX not gzip: %v", err)
|
||||
}
|
||||
tarBytes, _ := io.ReadAll(zr)
|
||||
tr := tar.NewReader(bytes.NewReader(tarBytes))
|
||||
// The index is served (empty) at once but lists the package only once the
|
||||
// upload's metadata is parsed asynchronously; poll until it does.
|
||||
var index string
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("APKINDEX not tar: %v", err)
|
||||
}
|
||||
if hdr.Name == "APKINDEX" {
|
||||
b, _ := io.ReadAll(tr)
|
||||
index = string(b)
|
||||
for deadline := time.Now().Add(15 * time.Second); !strings.Contains(index, "P:e2e-testpkg") && time.Now().Before(deadline); time.Sleep(250 * time.Millisecond) {
|
||||
resp, body := doRequest(t, http.MethodGet, api("/api/v1/local/local-alpine/x86_64/APKINDEX.tar.gz"), nil, "")
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("APKINDEX: status %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
index = apkIndexText(t, body)
|
||||
}
|
||||
for _, want := range []string{"P:e2e-testpkg", "V:1.0-r0", "A:x86_64", "C:Q1", "S:", "I:"} {
|
||||
if !strings.Contains(index, want) {
|
||||
@@ -183,7 +167,7 @@ func TestLocalAlpineIndex(t *testing.T) {
|
||||
}
|
||||
|
||||
// The .apk downloads back byte-identical from its arch path.
|
||||
resp, body = doRequest(t, http.MethodGet, api("/api/v1/local/local-alpine/x86_64/e2e-testpkg-1.0-r0.apk"), nil, "")
|
||||
resp, body := doRequest(t, http.MethodGet, api("/api/v1/local/local-alpine/x86_64/e2e-testpkg-1.0-r0.apk"), nil, "")
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("download apk: status %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
@@ -191,3 +175,25 @@ func TestLocalAlpineIndex(t *testing.T) {
|
||||
t.Fatalf("apk content mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func apkIndexText(t *testing.T, body []byte) string {
|
||||
t.Helper()
|
||||
zr, err := gzip.NewReader(bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("APKINDEX not gzip: %v", err)
|
||||
}
|
||||
tr := tar.NewReader(zr)
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
return ""
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("APKINDEX not tar: %v", err)
|
||||
}
|
||||
if hdr.Name == "APKINDEX" {
|
||||
b, _ := io.ReadAll(tr)
|
||||
return string(b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,13 +17,18 @@ import (
|
||||
const puppetHelloMetadata = `{"name":"unkin-hello","version":"0.1.0","author":"unkin","summary":"e2e","license":"Apache-2.0","source":"https://example.invalid","dependencies":[{"name":"puppetlabs/stdlib","version_requirement":">= 4.0.0 < 10.0.0"}]}`
|
||||
|
||||
func puppetModuleTarball(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
return puppetTarball(t, "unkin-hello-0.1.0", puppetHelloMetadata)
|
||||
}
|
||||
|
||||
func puppetTarball(t *testing.T, dir, metadata string) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
zw := gzip.NewWriter(&buf)
|
||||
tw := tar.NewWriter(zw)
|
||||
for name, body := range map[string]string{
|
||||
"unkin-hello-0.1.0/metadata.json": puppetHelloMetadata,
|
||||
"unkin-hello-0.1.0/manifests/init.pp": "class hello {}\n",
|
||||
dir + "/metadata.json": metadata,
|
||||
dir + "/manifests/init.pp": "class stub {}\n",
|
||||
} {
|
||||
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: int64(len(body)), ModTime: time.Now()}); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
//go:build dockere2e
|
||||
|
||||
package e2edocker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestVirtualPuppetInstall puts unkin-hello and its stdlib dependency on two
|
||||
// puppet locals and installs through a virtual over both with the real puppet
|
||||
// module tool (dependencies resolved) and r10k, downloads 302ing to each owner.
|
||||
func TestVirtualPuppetInstall(t *testing.T) {
|
||||
network := os.Getenv("COMPOSE_NETWORK")
|
||||
internal := strings.TrimRight(os.Getenv("ARTIFACTAPI_INTERNAL"), "/")
|
||||
if network == "" || internal == "" {
|
||||
t.Skip("COMPOSE_NETWORK/ARTIFACTAPI_INTERNAL not set; run via scripts/docker-e2e.sh")
|
||||
}
|
||||
if _, err := exec.LookPath("docker"); err != nil {
|
||||
t.Skip("docker not available on the test host")
|
||||
}
|
||||
|
||||
id := time.Now().UnixNano()
|
||||
a, b, virt := fmt.Sprintf("e2e-puppet-a-%d", id), fmt.Sprintf("e2e-puppet-b-%d", id), fmt.Sprintf("e2e-puppet-v-%d", id)
|
||||
modules := map[string]struct{ file, dir, meta string }{
|
||||
a: {"unkin-hello-0.1.0.tar.gz", "unkin-hello-0.1.0", puppetHelloMetadata},
|
||||
b: {"puppetlabs-stdlib-9.6.0.tar.gz", "puppetlabs-stdlib-9.6.0", `{"name":"puppetlabs-stdlib","version":"9.6.0","author":"puppetlabs","summary":"stub","license":"Apache-2.0","source":"https://example.invalid","dependencies":[]}`},
|
||||
}
|
||||
for repo, m := range modules {
|
||||
createRepo(t, fmt.Sprintf(`{"name":%q,"package_type":"puppet","repo_type":"local"}`, repo))
|
||||
defer deleteRepo(t, repo)
|
||||
if resp, body := doRequest(t, http.MethodPut, api("/api/v2/remotes/"+repo+"/files/"+m.file), puppetTarball(t, m.dir, m.meta), "application/gzip"); resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("upload %s: %d %s", m.file, resp.StatusCode, body)
|
||||
}
|
||||
defer doRequest(t, http.MethodDelete, api("/api/v2/remotes/"+repo+"/files/v3/files/"+m.file), nil, "")
|
||||
if resp, body := getEventually(t, api("/api/v1/local/"+repo+"/v3/releases/"+strings.TrimSuffix(m.file, ".tar.gz")), 15*time.Second); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("%s never indexed: %d %s", m.file, resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
createVirtual(t, fmt.Sprintf(`{"name":%q,"package_type":"puppet","members":[%q,%q]}`, virt, a, b))
|
||||
defer deleteVirtual(t, virt)
|
||||
|
||||
forge := internal + "/api/v1/virtual/" + virt
|
||||
run := func(image, script string) string {
|
||||
t.Helper()
|
||||
out, err := exec.Command("docker", "run", "--rm", "--network", network, "--entrypoint", "sh", image, "-c", "set -eu; "+script).CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("%s failed: %v\n%s", image, err, out)
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
out := run("ghcr.io/openvoxproject/openvoxagent:latest", "export PATH=/opt/puppetlabs/bin:$PATH; puppet module install unkin-hello --module_repository "+forge+" --target-dir /tmp/mods && puppet module list --color=false --modulepath /tmp/mods")
|
||||
for _, want := range []string{"unkin-hello (v0.1.0)", "puppetlabs-stdlib (v9.6.0)"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("puppet module install missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
|
||||
out = run("ghcr.io/voxpupuli/r10k:latest", "cd /tmp && printf 'forge:\\n baseurl: "+forge+"\\n' > r10k.yaml && "+
|
||||
"printf \"mod 'unkin-hello', '0.1.0'\\nmod 'puppetlabs-stdlib', '9.6.0'\\n\" > Puppetfile && "+
|
||||
"r10k puppetfile install -c r10k.yaml && cat modules/hello/metadata.json modules/stdlib/metadata.json")
|
||||
if !strings.Contains(out, `"unkin-hello"`) || !strings.Contains(out, `"puppetlabs-stdlib"`) {
|
||||
t.Fatalf("r10k did not install both modules:\n%s", out)
|
||||
}
|
||||
|
||||
noRedirect := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
||||
for repo, m := range modules {
|
||||
resp, err := noRedirect.Get(api("/api/v1/virtual/" + virt + "/v3/files/" + m.file))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
if loc := resp.Header.Get("Location"); resp.StatusCode != http.StatusFound || !strings.HasSuffix(loc, "/api/v1/local/"+repo+"/v3/files/"+m.file) {
|
||||
t.Errorf("%s = %d %q, want 302 to %s", m.file, resp.StatusCode, loc, repo)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -2,20 +2,24 @@ package v1
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
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,11 +119,11 @@ 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) {
|
||||
http.Error(w, proxyErr.Message, proxyErr.Status)
|
||||
writeProxyError(w, proxyErr)
|
||||
return
|
||||
}
|
||||
slog.Error("proxy fetch failed", "remote", remote.Name, "path", path, "error", err)
|
||||
@@ -147,6 +151,24 @@ func (h *ProxyHandler) serveFetch(w http.ResponseWriter, r *http.Request, remote
|
||||
io.Copy(w, body)
|
||||
}
|
||||
|
||||
// writeProxyError answers a quarantine refusal as JSON and anything else as text.
|
||||
func writeProxyError(w http.ResponseWriter, pe *proxy.ProxyError) {
|
||||
if pe.RetryAfter > 0 {
|
||||
w.Header().Set("Retry-After", strconv.Itoa(pe.RetryAfter))
|
||||
}
|
||||
if pe.Published.IsZero() {
|
||||
http.Error(w, pe.Message, pe.Status)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(pe.Status)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": pe.Message,
|
||||
"published": pe.Published.Format(time.RFC3339),
|
||||
"available": pe.Available.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
func rewritesMutable(prov provider.Provider, path string) bool {
|
||||
_, sr := prov.(provider.ServeRewriter)
|
||||
_, rr := prov.(provider.ResponseRewriter)
|
||||
@@ -174,7 +196,7 @@ func (h *ProxyHandler) handleProxyHead(w http.ResponseWriter, r *http.Request) {
|
||||
if err != nil {
|
||||
var proxyErr *proxy.ProxyError
|
||||
if errors.As(err, &proxyErr) {
|
||||
http.Error(w, proxyErr.Message, proxyErr.Status)
|
||||
writeProxyError(w, proxyErr)
|
||||
return
|
||||
}
|
||||
slog.Error("proxy head failed", "remote", remote.Name, "path", path, "error", err)
|
||||
@@ -209,6 +231,18 @@ 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.PackagePuppet {
|
||||
h.serveVirtualPuppet(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 +308,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 +370,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) {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/proxy"
|
||||
)
|
||||
|
||||
func TestWriteProxyErrorRetryAfter(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
writeProxyError(w, &proxy.ProxyError{Status: 503, Message: "quarantine check unavailable", RetryAfter: 30})
|
||||
if w.Code != 503 || w.Header().Get("Retry-After") != "30" {
|
||||
t.Errorf("code %d, Retry-After %q", w.Code, w.Header().Get("Retry-After"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/provider/puppet"
|
||||
"git.unkin.net/unkin/artifactapi/internal/virtual"
|
||||
"git.unkin.net/unkin/artifactapi/pkg/models"
|
||||
)
|
||||
|
||||
// serveVirtualPuppet serves the merged Forge v3 module and release listings
|
||||
// and 302s release details and downloads to the member owning that release.
|
||||
func (h *ProxyHandler) serveVirtualPuppet(w http.ResponseWriter, r *http.Request, virt models.Virtual, path, proxyBaseURL string) {
|
||||
var doc any
|
||||
var err error
|
||||
switch {
|
||||
case path == "v3/releases":
|
||||
q := r.URL.Query()
|
||||
slug := puppet.NormalizeSlug(q.Get("module"))
|
||||
if !puppet.ValidSlug(slug) {
|
||||
forgeError(w, http.StatusBadRequest, "module is required")
|
||||
return
|
||||
}
|
||||
var d *virtual.PuppetDoc
|
||||
d, err = h.virtualEngine.PuppetReleases(r.Context(), virt, slug)
|
||||
releases := []any{}
|
||||
if errors.Is(err, virtual.ErrNotFound) {
|
||||
err = nil
|
||||
} else if err == nil {
|
||||
for _, rel := range d.Releases {
|
||||
releases = append(releases, rel)
|
||||
}
|
||||
}
|
||||
doc = puppet.ReleasesPage(slug, releases, q)
|
||||
case strings.HasPrefix(path, "v3/modules/"):
|
||||
slug := puppet.NormalizeSlug(strings.TrimPrefix(path, "v3/modules/"))
|
||||
if !puppet.ValidSlug(slug) {
|
||||
puppet.NotFound(w)
|
||||
return
|
||||
}
|
||||
var d *virtual.PuppetDoc
|
||||
if d, err = h.virtualEngine.PuppetReleases(r.Context(), virt, slug); err == nil {
|
||||
doc, err = virtual.PuppetModule(d)
|
||||
}
|
||||
default:
|
||||
var loc string
|
||||
loc, err = h.virtualEngine.PuppetRedirect(r.Context(), virt, path, proxyBaseURL)
|
||||
if err == nil {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Vary", "Host, X-Forwarded-Proto")
|
||||
http.Redirect(w, r, loc, http.StatusFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, virtual.ErrNotFound):
|
||||
puppet.NotFound(w)
|
||||
case errors.Is(err, virtual.ErrBadPath):
|
||||
forgeError(w, http.StatusBadRequest, err.Error())
|
||||
case err != nil:
|
||||
slog.Error("virtual puppet fetch failed", "virtual", virt.Name, "path", path, "error", err)
|
||||
forgeError(w, http.StatusBadGateway, "bad gateway")
|
||||
default:
|
||||
w.Header().Set("X-Artifact-Source", "virtual")
|
||||
puppet.WriteJSON(w, doc)
|
||||
}
|
||||
}
|
||||
|
||||
func forgeError(w http.ResponseWriter, code int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"message": http.StatusText(code), "errors": []string{msg}})
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/provider"
|
||||
"git.unkin.net/unkin/artifactapi/internal/virtual"
|
||||
"git.unkin.net/unkin/artifactapi/pkg/models"
|
||||
)
|
||||
|
||||
func TestPuppetVirtualOverLocals(t *testing.T) {
|
||||
h, srv := npmTestServer(t)
|
||||
h.virtualEngine = virtual.NewEngine(h.db, nil)
|
||||
a, b := npmRepo(t, h, models.PackagePuppet), npmRepo(t, h, models.PackagePuppet)
|
||||
for _, p := range []struct{ repo, version string }{{a, "0.1.0"}, {b, "0.1.0"}, {b, "0.2.0"}} {
|
||||
file := "v3/files/unkin-hello-" + p.version + ".tar.gz"
|
||||
meta := &provider.PuppetMetadata{RepoName: p.repo, FilePath: file, ContentHash: "sha256:00", Slug: "unkin-hello", Version: p.version, MetadataJSON: `{"name":"unkin-hello","version":"` + p.version + `"}`}
|
||||
if err := h.db.InsertPuppetMetadata(context.Background(), meta); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
virt := "v" + a
|
||||
if err := h.db.CreateVirtual(context.Background(), &models.Virtual{Name: virt, PackageType: models.PackagePuppet, Members: []string{a, b}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base := srv.URL + "/api/v1/virtual/" + virt
|
||||
|
||||
var page struct {
|
||||
Pagination struct {
|
||||
Total int
|
||||
Next *string
|
||||
}
|
||||
Results []struct {
|
||||
Version string
|
||||
FileURI string `json:"file_uri"`
|
||||
}
|
||||
}
|
||||
getJSON(t, base+"/v3/releases?module=unkin/hello&limit=1", &page)
|
||||
if page.Pagination.Total != 2 || len(page.Results) != 1 || page.Results[0].Version != "0.2.0" || page.Pagination.Next == nil {
|
||||
t.Fatalf("page 1 = %+v", page)
|
||||
}
|
||||
if *page.Pagination.Next != "/v3/releases?limit=1&module=unkin-hello&offset=1" || page.Results[0].FileURI != "/v3/files/unkin-hello-0.2.0.tar.gz" {
|
||||
t.Fatalf("links not relative to the forge root: next %q file_uri %q", *page.Pagination.Next, page.Results[0].FileURI)
|
||||
}
|
||||
getJSON(t, base+*page.Pagination.Next, &page)
|
||||
if len(page.Results) != 1 || page.Results[0].Version != "0.1.0" || page.Pagination.Next != nil {
|
||||
t.Fatalf("page 2 = %+v", page)
|
||||
}
|
||||
|
||||
var mod struct {
|
||||
CurrentRelease struct{ Version string } `json:"current_release"`
|
||||
Releases []struct{ Version string }
|
||||
}
|
||||
getJSON(t, base+"/v3/modules/unkin-hello", &mod)
|
||||
if mod.CurrentRelease.Version != "0.2.0" || len(mod.Releases) != 2 {
|
||||
t.Fatalf("module = %+v", mod)
|
||||
}
|
||||
|
||||
noRedirect := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
||||
for p, want := range map[string]string{
|
||||
"/v3/files/unkin-hello-0.1.0.tar.gz": "/api/v1/local/" + a + "/v3/files/unkin-hello-0.1.0.tar.gz",
|
||||
"/v3/releases/unkin-hello-0.2.0": "/api/v1/local/" + b + "/v3/releases/unkin-hello-0.2.0",
|
||||
} {
|
||||
resp, err := noRedirect.Get(base + p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusFound || resp.Header.Get("Location") != srv.URL+want || resp.Header.Get("Cache-Control") != "no-store" {
|
||||
t.Errorf("%s = %d %q %q, want 302 to %s", p, resp.StatusCode, resp.Header.Get("Location"), resp.Header.Get("Cache-Control"), want)
|
||||
}
|
||||
}
|
||||
|
||||
getJSON(t, base+"/v3/releases?module=unkin-absent", &page)
|
||||
if page.Pagination.Total != 0 || len(page.Results) != 0 {
|
||||
t.Fatalf("absent module = %+v", page)
|
||||
}
|
||||
for p, want := range map[string]int{
|
||||
"/v3/releases": http.StatusBadRequest,
|
||||
"/v3/modules/unkin-absent": http.StatusNotFound,
|
||||
"/v3/files/unkin-hello-0.9.0.tar.gz": http.StatusNotFound,
|
||||
} {
|
||||
if code, body := npmDo(t, http.MethodGet, base+p, ""); code != want || !strings.Contains(body, `"errors"`) {
|
||||
t.Errorf("%s = %d %s, want %d", p, code, body, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,7 +72,7 @@ func (h *RemotesHandler) get(w http.ResponseWriter, r *http.Request) {
|
||||
const defaultMutableTTL = 3600
|
||||
|
||||
func (h *RemotesHandler) create(w http.ResponseWriter, r *http.Request) {
|
||||
remote := models.Remote{MutableTTL: defaultMutableTTL}
|
||||
remote := models.Remote{MutableTTL: defaultMutableTTL, QuarantineDays: models.DefaultQuarantineDays}
|
||||
if err := json.NewDecoder(r.Body).Decode(&remote); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
@@ -104,6 +104,14 @@ func (h *RemotesHandler) create(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := remote.ValidateSyncInterval(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := remote.ValidateQuarantine(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := h.db.CreateRemote(r.Context(), &remote); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
@@ -126,6 +134,7 @@ func (h *RemotesHandler) update(w http.ResponseWriter, r *http.Request) {
|
||||
var remote models.Remote
|
||||
if oldKnown {
|
||||
remote.MutableTTL = existing.MutableTTL
|
||||
remote.QuarantineDays = existing.QuarantineDays
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&remote); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
@@ -144,6 +153,14 @@ func (h *RemotesHandler) update(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := remote.ValidateSyncInterval(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := remote.ValidateQuarantine(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := h.db.UpdateRemote(r.Context(), &remote); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/database"
|
||||
)
|
||||
|
||||
func TestCacheRepodataRoundTrip(t *testing.T) {
|
||||
if testDSN == "" {
|
||||
t.Skip("Docker unavailable")
|
||||
}
|
||||
db, err := database.New(testDSN)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
h := NewRemotesHandler(db, nil, nil).Routes()
|
||||
get := func() bool {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, httptest.NewRequest("GET", "/cache-rd", nil))
|
||||
var got struct {
|
||||
CacheRepodata *bool `json:"cache_repodata"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil || got.CacheRepodata == nil {
|
||||
t.Fatalf("get = %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
return *got.CacheRepodata
|
||||
}
|
||||
const body = `"package_type":"github_rpm","base_url":"https://api.github.com"`
|
||||
|
||||
steps := []struct {
|
||||
method, path, json string
|
||||
want bool
|
||||
}{
|
||||
{"POST", "/", `{"name":"cache-rd",` + body + `}`, false},
|
||||
{"PUT", "/cache-rd", `{` + body + `,"cache_repodata":true}`, true},
|
||||
{"PUT", "/cache-rd", `{` + body + `,"cache_repodata":false}`, false},
|
||||
}
|
||||
for i, s := range steps {
|
||||
if c := do(t, h, s.method, s.path, s.json); c/100 != 2 {
|
||||
t.Fatalf("step %d %s %s = %d", i, s.method, s.path, c)
|
||||
}
|
||||
if got := get(); got != s.want {
|
||||
t.Errorf("step %d: cache_repodata = %v, want %v", i, got, s.want)
|
||||
}
|
||||
}
|
||||
if c := do(t, h, "POST", "/", `{"name":"cache-rd-on",`+body+`,"cache_repodata":true}`); c != 201 {
|
||||
t.Fatalf("create with cache_repodata = %d", c)
|
||||
}
|
||||
if r, err := db.GetRemote(t.Context(), "cache-rd-on"); err != nil || !r.CacheRepodata {
|
||||
t.Fatalf("create did not store cache_repodata: %v %v", r, err)
|
||||
}
|
||||
}
|
||||
@@ -55,3 +55,120 @@ func TestMutableTTLOmittedVsZero(t *testing.T) {
|
||||
t.Errorf("explicit 0 stored as %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// sync_interval round-trips through the API and DB on github_* remotes and is
|
||||
// rejected when negative, over the maximum, or on a type without a background syncer.
|
||||
func TestSyncIntervalAPI(t *testing.T) {
|
||||
if testDSN == "" {
|
||||
t.Skip("Docker unavailable")
|
||||
}
|
||||
db, err := database.New(testDSN)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
h := NewRemotesHandler(db, nil, nil).Routes()
|
||||
interval := func(name string) int {
|
||||
t.Helper()
|
||||
r, err := db.GetRemote(context.Background(), name)
|
||||
if err != nil {
|
||||
t.Fatalf("get %s: %v", name, err)
|
||||
}
|
||||
return r.SyncInterval
|
||||
}
|
||||
const gh = `"package_type":"github_deb","base_url":"https://api.github.com/repos/acme/tools","releases_remote":"github"`
|
||||
|
||||
if c := do(t, h, "POST", "/", `{"name":"si-gh",`+gh+`}`); c != 201 {
|
||||
t.Fatalf("create unset = %d", c)
|
||||
}
|
||||
if got := interval("si-gh"); got != 0 {
|
||||
t.Fatalf("unset sync_interval stored as %d", got)
|
||||
}
|
||||
if c := do(t, h, "PUT", "/si-gh", `{`+gh+`,"sync_interval":3600}`); c != 200 {
|
||||
t.Fatalf("update = %d", c)
|
||||
}
|
||||
if got := interval("si-gh"); got != 3600 {
|
||||
t.Fatalf("sync_interval = %d, want 3600", got)
|
||||
}
|
||||
|
||||
for _, body := range []string{
|
||||
`{"name":"si-neg",` + gh + `,"sync_interval":-1}`,
|
||||
`{"name":"si-max",` + gh + `,"sync_interval":2592001}`,
|
||||
`{"name":"si-int32",` + gh + `,"sync_interval":2147483648}`,
|
||||
`{"name":"si-rpm","package_type":"rpm","base_url":"https://example.com/repo","sync_interval":60}`,
|
||||
} {
|
||||
if c := do(t, h, "POST", "/", body); c != 400 {
|
||||
t.Errorf("%s = %d, want 400", body, c)
|
||||
}
|
||||
}
|
||||
if c := do(t, h, "PUT", "/si-gh", `{`+gh+`,"sync_interval":-1}`); c != 400 {
|
||||
t.Errorf("update negative = %d, want 400", c)
|
||||
}
|
||||
if got := interval("si-gh"); got != 3600 {
|
||||
t.Errorf("rejected update changed sync_interval to %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuarantineDaysDefault(t *testing.T) {
|
||||
if testDSN == "" {
|
||||
t.Skip("Docker unavailable")
|
||||
}
|
||||
db, err := database.New(testDSN)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
h := NewRemotesHandler(db, nil, nil).Routes()
|
||||
days := func() int {
|
||||
t.Helper()
|
||||
r, err := db.GetRemote(context.Background(), "q-days")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return r.QuarantineDays
|
||||
}
|
||||
const body = `"package_type":"npm","base_url":"https://example.com","quarantine_enabled":true`
|
||||
for i, s := range []struct {
|
||||
method, path, json string
|
||||
want int
|
||||
}{
|
||||
{"POST", "/", `{"name":"q-days",` + body + `}`, 3},
|
||||
{"PUT", "/q-days", `{` + body + `,"quarantine_days":14}`, 14},
|
||||
{"PUT", "/q-days", `{` + body + `}`, 14},
|
||||
} {
|
||||
if c := do(t, h, s.method, s.path, s.json); c/100 != 2 {
|
||||
t.Fatalf("step %d = %d", i, c)
|
||||
}
|
||||
if got := days(); got != s.want {
|
||||
t.Errorf("step %d: quarantine_days = %d, want %d", i, got, s.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuarantineDaysValidation(t *testing.T) {
|
||||
if testDSN == "" {
|
||||
t.Skip("Docker unavailable")
|
||||
}
|
||||
db, err := database.New(testDSN)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
h := NewRemotesHandler(db, nil, nil).Routes()
|
||||
const body = `"package_type":"npm","base_url":"https://example.com"`
|
||||
for i, s := range []struct {
|
||||
method, path, json string
|
||||
want int
|
||||
}{
|
||||
{"POST", "/", `{"name":"q-val",` + body + `,"quarantine_enabled":true,"quarantine_days":0}`, 400},
|
||||
{"POST", "/", `{"name":"q-val",` + body + `,"quarantine_enabled":true,"quarantine_days":-1}`, 400},
|
||||
{"POST", "/", `{"name":"q-val",` + body + `,"quarantine_days":0}`, 201},
|
||||
{"PUT", "/q-val", `{` + body + `,"quarantine_enabled":true}`, 400},
|
||||
{"PUT", "/q-val", `{` + body + `,"quarantine_enabled":true,"quarantine_days":1}`, 200},
|
||||
{"PUT", "/q-val", `{` + body + `,"quarantine_enabled":true,"quarantine_days":0}`, 400},
|
||||
} {
|
||||
if c := do(t, h, s.method, s.path, s.json); c != s.want {
|
||||
t.Errorf("step %d %s %s = %d, want %d", i, s.method, s.path, c, s.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/database"
|
||||
)
|
||||
|
||||
func TestVirtualParallelFetchRoundTrip(t *testing.T) {
|
||||
if testDSN == "" {
|
||||
t.Skip("Docker unavailable")
|
||||
}
|
||||
db, err := database.New(testDSN)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
h := NewVirtualsHandler(db).Routes()
|
||||
parallel := func() bool {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, httptest.NewRequest("GET", "/vpf", nil))
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil || w.Code != 200 {
|
||||
t.Fatalf("get = %d %s", w.Code, w.Body)
|
||||
}
|
||||
v, ok := got["parallel_fetch"].(bool)
|
||||
if !ok {
|
||||
t.Fatalf("parallel_fetch missing from %s", w.Body)
|
||||
}
|
||||
return v
|
||||
}
|
||||
const body = `"package_type":"rpm","members":["a"]`
|
||||
|
||||
steps := []struct {
|
||||
method, path, json string
|
||||
want bool
|
||||
}{
|
||||
{"POST", "/", `{"name":"vpf",` + body + `}`, false},
|
||||
{"PUT", "/vpf", `{` + body + `,"parallel_fetch":true}`, true},
|
||||
{"PUT", "/vpf", `{` + body + `,"parallel_fetch":false}`, false},
|
||||
}
|
||||
for i, s := range steps {
|
||||
if c := do(t, h, s.method, s.path, s.json); c/100 != 2 {
|
||||
t.Fatalf("step %d %s %s = %d", i, s.method, s.path, c)
|
||||
}
|
||||
if got := parallel(); got != s.want {
|
||||
t.Errorf("step %d: parallel_fetch = %v, want %v", i, got, s.want)
|
||||
}
|
||||
}
|
||||
if c := do(t, h, "DELETE", "/vpf", ""); c != 204 {
|
||||
t.Fatalf("delete = %d", c)
|
||||
}
|
||||
}
|
||||
+93
-17
@@ -2,7 +2,9 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"slices"
|
||||
@@ -104,42 +106,116 @@ func (b *BearerOIDC) discoverOnce(done chan struct{}) (v *oidc.IDTokenVerifier,
|
||||
// Require gates every request through next on a valid token. A nil receiver
|
||||
// passes everything through.
|
||||
func (b *BearerOIDC) Require(next http.Handler) http.Handler {
|
||||
return b.require(next, "Bearer")
|
||||
}
|
||||
|
||||
func (b *BearerOIDC) require(next http.Handler, challenge string) http.Handler {
|
||||
if b == nil {
|
||||
return next
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
scheme, raw, _ := strings.Cut(strings.TrimSpace(r.Header.Get("Authorization")), " ")
|
||||
raw = strings.TrimSpace(raw)
|
||||
if !strings.EqualFold(scheme, "Bearer") || raw == "" {
|
||||
unauthorized(w, "missing bearer token")
|
||||
raw := token(r)
|
||||
if raw == "" {
|
||||
unauthorized(w, challenge, "missing token")
|
||||
return
|
||||
}
|
||||
v, err := b.getVerifier(r.Context())
|
||||
if err != nil {
|
||||
sub, err := b.verify(r.Context(), raw)
|
||||
if errors.Is(err, errIssuerUnavailable) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_, _ = w.Write([]byte(`{"error":"token issuer unavailable"}`))
|
||||
return
|
||||
}
|
||||
tok, err := v.Verify(r.Context(), raw)
|
||||
if err != nil {
|
||||
slog.Info("bearer token rejected", "error", err)
|
||||
unauthorized(w, "invalid token")
|
||||
unauthorized(w, challenge, "invalid token")
|
||||
return
|
||||
}
|
||||
if len(b.audiences) > 0 && !slices.ContainsFunc(tok.Audience, func(a string) bool { return slices.Contains(b.audiences, a) }) {
|
||||
slog.Info("bearer token rejected", "error", "audience not allowed", "aud", tok.Audience)
|
||||
unauthorized(w, "invalid token")
|
||||
return
|
||||
}
|
||||
slog.Info("bearer token accepted", "sub", tok.Subject, "method", r.Method, "path", r.URL.Path)
|
||||
slog.Info("bearer token accepted", "sub", sub, "method", r.Method, "path", r.URL.Path)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (b *BearerOIDC) verify(ctx context.Context, raw string) (string, error) {
|
||||
v, err := b.getVerifier(ctx)
|
||||
if err != nil {
|
||||
return "", errIssuerUnavailable
|
||||
}
|
||||
tok, err := v.Verify(ctx, raw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(b.audiences) > 0 && !slices.ContainsFunc(tok.Audience, func(a string) bool { return slices.Contains(b.audiences, a) }) {
|
||||
return "", fmt.Errorf("audience %v not allowed", tok.Audience)
|
||||
}
|
||||
return tok.Subject, nil
|
||||
}
|
||||
|
||||
// token reads the JWT from `Bearer`/`Token` credentials, the password of
|
||||
// `Basic` credentials (any username), or a bare header value as `gem push`
|
||||
// sends its API key.
|
||||
func token(r *http.Request) string {
|
||||
if _, pass, ok := r.BasicAuth(); ok {
|
||||
return pass
|
||||
}
|
||||
scheme, raw, found := strings.Cut(strings.TrimSpace(r.Header.Get("Authorization")), " ")
|
||||
if !found {
|
||||
return scheme
|
||||
}
|
||||
if strings.EqualFold(scheme, "Bearer") || strings.EqualFold(scheme, "Token") {
|
||||
return strings.TrimSpace(raw)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// RequireWrites gates only mutating methods.
|
||||
func (b *BearerOIDC) RequireWrites(next http.Handler) http.Handler {
|
||||
gated := b.Require(next)
|
||||
return writesOnly(next, b.Require(next))
|
||||
}
|
||||
|
||||
// RequireRegistryWrites gates Docker Registry V2 writes behind the token flow
|
||||
// Docker clients need: an unauthenticated ping gets a Bearer challenge naming
|
||||
// realm, which serves the Basic password back as the token once verified, or
|
||||
// an anonymous token so pulls stay open.
|
||||
func (b *BearerOIDC) RequireRegistryWrites(next http.Handler, realm func(*http.Request) string) http.Handler {
|
||||
if b == nil {
|
||||
return next
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
challenge := `Bearer realm="` + realm(r) + `",service="artifactapi"`
|
||||
hasAuth := r.Header.Get("Authorization") != ""
|
||||
switch {
|
||||
case r.URL.Path == "/v2/token":
|
||||
writeToken(w, b.registryToken(r))
|
||||
case r.URL.Path == "/v2/" && !hasAuth:
|
||||
unauthorized(w, challenge, "authentication required")
|
||||
default:
|
||||
writesOnly(next, b.require(next, challenge)).ServeHTTP(w, r)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// registryToken echoes a valid credential back as the registry token. Missing
|
||||
// or invalid credentials (e.g. an expired `docker login`) get the anonymous
|
||||
// token so pulls keep working; writes are still verified per request.
|
||||
func (b *BearerOIDC) registryToken(r *http.Request) string {
|
||||
raw := token(r)
|
||||
if raw == "" {
|
||||
return "anonymous"
|
||||
}
|
||||
if _, err := b.verify(r.Context(), raw); err != nil {
|
||||
slog.Info("registry credential rejected, issuing anonymous token", "error", err)
|
||||
return "anonymous"
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func writeToken(w http.ResponseWriter, tok string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"token": tok, "access_token": tok})
|
||||
}
|
||||
|
||||
func writesOnly(next, gated http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
||||
@@ -150,9 +226,9 @@ func (b *BearerOIDC) RequireWrites(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
func unauthorized(w http.ResponseWriter, msg string) {
|
||||
func unauthorized(w http.ResponseWriter, challenge, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("WWW-Authenticate", "Bearer")
|
||||
w.Header().Set("WWW-Authenticate", challenge)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"error":"` + msg + `"}`))
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
@@ -253,3 +254,90 @@ func TestRequireWritesLeavesReadsOpen(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func basic(user, pass string) string {
|
||||
return "Basic " + base64.StdEncoding.EncodeToString([]byte(user+":"+pass))
|
||||
}
|
||||
|
||||
func TestTokenSchemes(t *testing.T) {
|
||||
iss := testsupport.NewOIDCIssuer()
|
||||
defer iss.Close()
|
||||
h := NewBearerOIDC(iss.URL, []string{"artifactapi"}).RequireWrites(ok)
|
||||
valid := iss.Token(nil, "artifactapi", "ci", time.Hour)
|
||||
cases := []struct {
|
||||
name string
|
||||
authz string
|
||||
want int
|
||||
}{
|
||||
{"bearer", "Bearer " + valid, http.StatusNoContent},
|
||||
{"token scheme", "Token " + valid, http.StatusNoContent},
|
||||
{"basic password", basic("anyone", valid), http.StatusNoContent},
|
||||
{"basic empty user", basic("", valid), http.StatusNoContent},
|
||||
{"bare jwt", valid, http.StatusNoContent},
|
||||
{"basic wrong aud", basic("ci", iss.Token(nil, "nope", "ci", time.Hour)), http.StatusUnauthorized},
|
||||
{"basic token as user", basic(valid, "x"), http.StatusUnauthorized},
|
||||
{"bare garbage", "garbage", http.StatusUnauthorized},
|
||||
{"unknown scheme", "Digest " + valid, http.StatusUnauthorized},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := serveAuth(h, http.MethodPut, c.authz); got != c.want {
|
||||
t.Errorf("%s: status %d, want %d", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireRegistryWrites(t *testing.T) {
|
||||
iss := testsupport.NewOIDCIssuer()
|
||||
defer iss.Close()
|
||||
h := NewBearerOIDC(iss.URL, []string{"artifactapi"}).RequireRegistryWrites(ok, func(*http.Request) string { return "https://reg.example/v2/token" })
|
||||
valid := iss.Token(nil, "artifactapi", "ci", time.Hour)
|
||||
do := func(method, path, authz string) *httptest.ResponseRecorder {
|
||||
r := httptest.NewRequest(method, path, nil)
|
||||
if authz != "" {
|
||||
r.Header.Set("Authorization", authz)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
challenge := `Bearer realm="https://reg.example/v2/token",service="artifactapi"`
|
||||
|
||||
w := do(http.MethodGet, "/v2/", "")
|
||||
if w.Code != http.StatusUnauthorized || w.Header().Get("WWW-Authenticate") != challenge {
|
||||
t.Errorf("anonymous ping: %d %q", w.Code, w.Header().Get("WWW-Authenticate"))
|
||||
}
|
||||
if w := do(http.MethodGet, "/v2/", "Bearer anonymous"); w.Code != http.StatusNoContent {
|
||||
t.Errorf("ping with token: %d", w.Code)
|
||||
}
|
||||
if w := do(http.MethodGet, "/v2/token", ""); w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"token":"anonymous"`) {
|
||||
t.Errorf("anonymous token: %d %s", w.Code, w.Body)
|
||||
}
|
||||
if w := do(http.MethodGet, "/v2/token", basic("ci", valid)); w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"token":"`+valid+`"`) {
|
||||
t.Errorf("token exchange: %d %s", w.Code, w.Body)
|
||||
}
|
||||
expired := iss.Token(nil, "artifactapi", "ci", -time.Minute)
|
||||
for _, authz := range []string{basic("ci", "bogus"), basic("ci", expired), "Bearer " + expired} {
|
||||
if w := do(http.MethodGet, "/v2/token", authz); w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"token":"anonymous"`) {
|
||||
t.Errorf("rejected credential %q: %d %s", authz, w.Code, w.Body)
|
||||
}
|
||||
}
|
||||
for _, m := range []string{http.MethodGet, http.MethodHead} {
|
||||
if w := do(m, "/v2/repo/img/manifests/t", ""); w.Code != http.StatusNoContent {
|
||||
t.Errorf("anonymous %s: %d", m, w.Code)
|
||||
}
|
||||
}
|
||||
for _, m := range []string{http.MethodPost, http.MethodPatch, http.MethodPut, http.MethodDelete} {
|
||||
w := do(m, "/v2/repo/img/blobs/uploads/", "Bearer anonymous")
|
||||
if w.Code != http.StatusUnauthorized || w.Header().Get("WWW-Authenticate") != challenge {
|
||||
t.Errorf("anonymous %s: %d %q", m, w.Code, w.Header().Get("WWW-Authenticate"))
|
||||
}
|
||||
for _, authz := range []string{"Bearer " + valid, basic("ci", valid)} {
|
||||
if w := do(m, "/v2/repo/img/blobs/uploads/", authz); w.Code != http.StatusNoContent {
|
||||
t.Errorf("authenticated %s: %d", m, w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
if got := serve(NewBearerOIDC("", nil).RequireRegistryWrites(ok, nil), http.MethodPut, ""); got != http.StatusNoContent {
|
||||
t.Errorf("disabled: status %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+14
@@ -84,6 +84,20 @@ func (r *Redis) GetETag(ctx context.Context, remote, path string) (string, error
|
||||
return val, err
|
||||
}
|
||||
|
||||
// FirstSeen records now as the first sighting of path unless one is already
|
||||
// stored, and returns the stored sighting.
|
||||
func (r *Redis) FirstSeen(ctx context.Context, remote, path string, ttl time.Duration) (time.Time, error) {
|
||||
key := fmt.Sprintf("firstseen:%s:%s", remote, path)
|
||||
if err := r.client.SetNX(ctx, key, time.Now().Unix(), ttl).Err(); err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
sec, err := r.client.Get(ctx, key).Int64()
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
return time.Unix(sec, 0), nil
|
||||
}
|
||||
|
||||
func (r *Redis) GetToken(ctx context.Context, key string) (string, error) {
|
||||
val, err := r.client.Get(ctx, "token:"+key).Result()
|
||||
if err == redis.Nil {
|
||||
|
||||
@@ -61,6 +61,8 @@ type Config struct {
|
||||
// Bearer-JWT auth on configuration writes; empty issuer disables it.
|
||||
OIDCIssuer string
|
||||
OIDCAudiences []string
|
||||
// AuthUploads extends that auth to local repo uploads, deletes and evictions.
|
||||
AuthUploads bool
|
||||
}
|
||||
|
||||
func (c *Config) DatabaseDSN() string {
|
||||
@@ -105,6 +107,13 @@ func Load() (*Config, error) {
|
||||
if oidcIssuer != "" && len(oidcAudiences) == 0 {
|
||||
return nil, fmt.Errorf("ARTIFACTAPI_OIDC_AUDIENCE is required when ARTIFACTAPI_OIDC_ISSUER is set")
|
||||
}
|
||||
authUploads, err := strconv.ParseBool(getenv("ARTIFACTAPI_AUTH_UPLOADS", "false"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid ARTIFACTAPI_AUTH_UPLOADS: %w", err)
|
||||
}
|
||||
if authUploads && oidcIssuer == "" {
|
||||
return nil, fmt.Errorf("ARTIFACTAPI_OIDC_ISSUER is required when ARTIFACTAPI_AUTH_UPLOADS is set")
|
||||
}
|
||||
|
||||
cfg := &Config{
|
||||
ListenAddr: getenv("LISTEN_ADDR", ":8000"),
|
||||
@@ -142,6 +151,7 @@ func Load() (*Config, error) {
|
||||
|
||||
OIDCIssuer: oidcIssuer,
|
||||
OIDCAudiences: oidcAudiences,
|
||||
AuthUploads: authUploads,
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
|
||||
@@ -81,3 +81,23 @@ func TestLoadOIDC(t *testing.T) {
|
||||
t.Error("expected error for issuer without audience")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAuthUploads(t *testing.T) {
|
||||
cfg, err := Load()
|
||||
if err != nil || cfg.AuthUploads {
|
||||
t.Fatalf("default: %v, AuthUploads=%v", err, cfg.AuthUploads)
|
||||
}
|
||||
t.Setenv("ARTIFACTAPI_AUTH_UPLOADS", "true")
|
||||
if _, err := Load(); err == nil {
|
||||
t.Error("expected error for AUTH_UPLOADS without issuer")
|
||||
}
|
||||
t.Setenv("ARTIFACTAPI_OIDC_ISSUER", "https://vault.example/v1/identity/oidc")
|
||||
t.Setenv("ARTIFACTAPI_OIDC_AUDIENCE", "artifactapi")
|
||||
if cfg, err := Load(); err != nil || !cfg.AuthUploads {
|
||||
t.Fatalf("enabled: %v, AuthUploads=%v", err, cfg.AuthUploads)
|
||||
}
|
||||
t.Setenv("ARTIFACTAPI_AUTH_UPLOADS", "maybe")
|
||||
if _, err := Load(); err == nil {
|
||||
t.Error("expected error for invalid ARTIFACTAPI_AUTH_UPLOADS")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,8 +30,8 @@ func (db *DB) ListGitHubAlpineRemotes(ctx context.Context) ([]models.Remote, err
|
||||
|
||||
// ClaimGitHubAlpineSyncLease atomically claims the per-remote github_alpine sync
|
||||
// lease. See claimSyncLease.
|
||||
func (db *DB) ClaimGitHubAlpineSyncLease(ctx context.Context, remoteName, owner string, freshness, lease time.Duration) (bool, string, error) {
|
||||
return db.claimSyncLease(ctx, "github_alpine_sync_state", remoteName, owner, freshness, lease)
|
||||
func (db *DB) ClaimGitHubAlpineSyncLease(ctx context.Context, remoteName, owner string, freshness, lease, interval, phase time.Duration) (bool, string, error) {
|
||||
return db.claimSyncLease(ctx, "github_alpine_sync_state", remoteName, owner, freshness, lease, interval, phase)
|
||||
}
|
||||
|
||||
// ReleaseGitHubAlpineSyncLease records a github_alpine scan outcome and frees the
|
||||
|
||||
@@ -79,13 +79,19 @@ func TestRemotesCRUD(t *testing.T) {
|
||||
if err != nil || got.BaseURL != "https://example.com" {
|
||||
t.Fatalf("get: %v %v", got, err)
|
||||
}
|
||||
if got.CacheRepodata {
|
||||
t.Error("cache_repodata should default to false")
|
||||
}
|
||||
got.BaseURL = "https://updated.example.com"
|
||||
got.CacheRepodata = true
|
||||
got.SyncInterval = 600
|
||||
got.ServeStale = true
|
||||
if err := testDB.UpdateRemote(ctx(), got); err != nil {
|
||||
t.Fatalf("update: %v", err)
|
||||
}
|
||||
got, _ = testDB.GetRemote(ctx(), "r-crud")
|
||||
if got.BaseURL != "https://updated.example.com" {
|
||||
t.Errorf("update not applied: %v", got.BaseURL)
|
||||
if got.BaseURL != "https://updated.example.com" || !got.CacheRepodata || got.SyncInterval != 600 || !got.ServeStale {
|
||||
t.Errorf("update not applied: %v %v %v %v", got.BaseURL, got.CacheRepodata, got.SyncInterval, got.ServeStale)
|
||||
}
|
||||
list, err := testDB.ListRemotes(ctx())
|
||||
if err != nil || len(list) == 0 {
|
||||
@@ -307,6 +313,68 @@ func TestLocalFiles(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A repo recreated under a deleted repo's name must not inherit its files or
|
||||
// metadata, or re-uploading the same path 409s.
|
||||
func TestDeleteRemoteDropsLocalFiles(t *testing.T) {
|
||||
requireDB(t)
|
||||
seedRemote(t, "r-recreate")
|
||||
hash := seedBlob(t, "recreatehash")
|
||||
if err := testDB.CreateLocalFile(ctx(), "r-recreate", "x86_64/a-1.0-r0.apk", hash); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := testDB.InsertAlpineMetadata(ctx(), &provider.AlpineMetadata{RepoName: "r-recreate", FilePath: "x86_64/a-1.0-r0.apk", ContentHash: hash, Name: "a", Version: "1.0-r0", Arch: "x86_64"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := testDB.DeleteRemote(ctx(), "r-recreate"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seedRemote(t, "r-recreate")
|
||||
defer testDB.DeleteRemote(ctx(), "r-recreate")
|
||||
if f, err := testDB.GetLocalFile(ctx(), "r-recreate", "x86_64/a-1.0-r0.apk"); err != nil || f != nil {
|
||||
t.Fatalf("recreated repo inherited local file: %v %v", f, err)
|
||||
}
|
||||
if rows, err := testDB.ListAlpineMetadataEntries(ctx(), "r-recreate"); err != nil || len(rows) != 0 {
|
||||
t.Fatalf("recreated repo inherited alpine metadata: %v %v", rows, err)
|
||||
}
|
||||
if err := testDB.CreateLocalFile(ctx(), "r-recreate", "x86_64/a-1.0-r0.apk", hash); err != nil {
|
||||
t.Fatalf("re-upload after recreate: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVirtualParallelFetchRoundTrip(t *testing.T) {
|
||||
requireDB(t)
|
||||
if err := testDB.CreateVirtual(ctx(), &models.Virtual{
|
||||
Name: "v-parallel", PackageType: models.PackageRPM, Members: []string{"a"}, ParallelFetch: true,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = testDB.DeleteVirtual(ctx(), "v-parallel") }()
|
||||
v, err := testDB.GetVirtual(ctx(), "v-parallel")
|
||||
if err != nil || !v.ParallelFetch {
|
||||
t.Fatalf("created parallel_fetch = %v, %v; want true", v, err)
|
||||
}
|
||||
v.ParallelFetch = false
|
||||
if err := testDB.UpdateVirtual(ctx(), v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vs, err := testDB.ListVirtuals(ctx())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, l := range vs {
|
||||
if l.Name == "v-parallel" && l.ParallelFetch {
|
||||
t.Fatal("updated parallel_fetch not persisted")
|
||||
}
|
||||
}
|
||||
if err := testDB.CreateVirtual(ctx(), &models.Virtual{Name: "v-serial", PackageType: models.PackageRPM, Members: []string{"a"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = testDB.DeleteVirtual(ctx(), "v-serial") }()
|
||||
if v, err := testDB.GetVirtual(ctx(), "v-serial"); err != nil || v.ParallelFetch {
|
||||
t.Fatalf("default parallel_fetch = %v, %v; want false", v, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVirtualsCRUD(t *testing.T) {
|
||||
requireDB(t)
|
||||
if err := testDB.CreateVirtual(ctx(), &models.Virtual{
|
||||
@@ -425,3 +493,44 @@ func TestRPMMetadata(t *testing.T) {
|
||||
t.Fatalf("list rpm rows: %v %v", len(rows), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRPMMetadataFingerprint(t *testing.T) {
|
||||
requireDB(t)
|
||||
seedRemote(t, "r-rpm-fp")
|
||||
fp := func() [2]int64 {
|
||||
t.Helper()
|
||||
c, m, err := testDB.RPMMetadataFingerprint(ctx(), "r-rpm-fp")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return [2]int64{c, m}
|
||||
}
|
||||
insert := func(path string) {
|
||||
t.Helper()
|
||||
if err := testDB.InsertRPMMetadata(ctx(), &provider.RPMMetadata{
|
||||
RepoName: "r-rpm-fp", FilePath: path, ContentHash: "sha256:" + path,
|
||||
Name: path, Version: "1", Release: "1", Arch: "noarch",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if got := fp(); got != [2]int64{0, 0} {
|
||||
t.Fatalf("empty fingerprint = %v", got)
|
||||
}
|
||||
insert("a.rpm")
|
||||
one := fp()
|
||||
insert("a.rpm")
|
||||
if fp() != one {
|
||||
t.Fatal("conflicting insert changed fingerprint")
|
||||
}
|
||||
insert("b.rpm")
|
||||
two := fp()
|
||||
if err := testDB.DeleteRPMMetadata(ctx(), "r-rpm-fp", "a.rpm"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
afterDelete := fp()
|
||||
if one[0] != 1 || two[0] != 2 || afterDelete[0] != 1 || afterDelete == one || afterDelete == two {
|
||||
t.Fatalf("fingerprints did not track changes: %v %v %v", one, two, afterDelete)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,8 +30,8 @@ func (db *DB) ListGitHubDebRemotes(ctx context.Context) ([]models.Remote, error)
|
||||
|
||||
// ClaimGitHubDebSyncLease atomically claims the per-remote github_deb sync
|
||||
// lease. See claimSyncLease.
|
||||
func (db *DB) ClaimGitHubDebSyncLease(ctx context.Context, remoteName, owner string, freshness, lease time.Duration) (bool, string, error) {
|
||||
return db.claimSyncLease(ctx, "github_deb_sync_state", remoteName, owner, freshness, lease)
|
||||
func (db *DB) ClaimGitHubDebSyncLease(ctx context.Context, remoteName, owner string, freshness, lease, interval, phase time.Duration) (bool, string, error) {
|
||||
return db.claimSyncLease(ctx, "github_deb_sync_state", remoteName, owner, freshness, lease, interval, phase)
|
||||
}
|
||||
|
||||
// ReleaseGitHubDebSyncLease records a github_deb scan outcome and frees the
|
||||
|
||||
@@ -29,7 +29,7 @@ func TestGitHubDebSyncLease(t *testing.T) {
|
||||
const lease = 15 * time.Minute
|
||||
freshness := time.Hour
|
||||
|
||||
claimed, etag, err := testDB.ClaimGitHubDebSyncLease(ctx(), name, "replica-1", freshness, lease)
|
||||
claimed, etag, err := testDB.ClaimGitHubDebSyncLease(ctx(), name, "replica-1", freshness, lease, 0, 0)
|
||||
if err != nil || !claimed {
|
||||
t.Fatalf("replica-1 first claim: claimed=%v err=%v", claimed, err)
|
||||
}
|
||||
@@ -37,7 +37,7 @@ func TestGitHubDebSyncLease(t *testing.T) {
|
||||
t.Fatalf("initial etag should be empty, got %q", etag)
|
||||
}
|
||||
|
||||
claimed2, _, err := testDB.ClaimGitHubDebSyncLease(ctx(), name, "replica-2", freshness, lease)
|
||||
claimed2, _, err := testDB.ClaimGitHubDebSyncLease(ctx(), name, "replica-2", freshness, lease, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("replica-2 claim err: %v", err)
|
||||
}
|
||||
@@ -49,7 +49,7 @@ func TestGitHubDebSyncLease(t *testing.T) {
|
||||
t.Fatalf("release: %v", err)
|
||||
}
|
||||
|
||||
claimed3, _, err := testDB.ClaimGitHubDebSyncLease(ctx(), name, "replica-2", freshness, lease)
|
||||
claimed3, _, err := testDB.ClaimGitHubDebSyncLease(ctx(), name, "replica-2", freshness, lease, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("replica-2 recency claim err: %v", err)
|
||||
}
|
||||
@@ -57,7 +57,7 @@ func TestGitHubDebSyncLease(t *testing.T) {
|
||||
t.Fatal("periodic claim succeeded inside the freshness window")
|
||||
}
|
||||
|
||||
claimed4, etag4, err := testDB.ClaimGitHubDebSyncLease(ctx(), name, "replica-2", 0, lease)
|
||||
claimed4, etag4, err := testDB.ClaimGitHubDebSyncLease(ctx(), name, "replica-2", 0, lease, 0, 0)
|
||||
if err != nil || !claimed4 {
|
||||
t.Fatalf("prime claim: claimed=%v err=%v", claimed4, err)
|
||||
}
|
||||
|
||||
@@ -34,8 +34,8 @@ func (db *DB) ListGitHubRPMRemotes(ctx context.Context) ([]models.Remote, error)
|
||||
|
||||
// ClaimGitHubSyncLease atomically claims the per-remote github_rpm sync lease.
|
||||
// See claimSyncLease.
|
||||
func (db *DB) ClaimGitHubSyncLease(ctx context.Context, remoteName, owner string, freshness, lease time.Duration) (bool, string, error) {
|
||||
return db.claimSyncLease(ctx, "github_rpm_sync_state", remoteName, owner, freshness, lease)
|
||||
func (db *DB) ClaimGitHubSyncLease(ctx context.Context, remoteName, owner string, freshness, lease, interval, phase time.Duration) (bool, string, error) {
|
||||
return db.claimSyncLease(ctx, "github_rpm_sync_state", remoteName, owner, freshness, lease, interval, phase)
|
||||
}
|
||||
|
||||
// ReleaseGitHubSyncLease records a github_rpm scan outcome and frees the lease.
|
||||
@@ -48,22 +48,32 @@ func (db *DB) ReleaseGitHubSyncLease(ctx context.Context, remoteName, owner stri
|
||||
// (claimed=true) only when the remote is due and no live lease is held by
|
||||
// another replica. Due means: a pending retry after a failed scan has reached
|
||||
// next_retry_at, or, with no retry pending, the remote was never synced or was
|
||||
// synced longer than freshness ago. A zero freshness (prime scans) ignores the
|
||||
// recency gate but still honours a pending retry's backoff. The returned etag is
|
||||
// the stored releases-list ETag, shared across replicas so a conditional request
|
||||
// last synced before the current slot (interval set) or longer than freshness
|
||||
// ago (otherwise). With interval set, slots start at phase past each multiple
|
||||
// of interval and a remote created inside the current slot with no state row
|
||||
// waits for the next.
|
||||
// Slots are computed from the DB clock so replicas agree whatever their own
|
||||
// clocks say. A zero freshness and interval (prime scans) ignores the recency
|
||||
// gate but still honours a pending retry's backoff. The returned etag is the
|
||||
// stored releases-list ETag, shared across replicas so a conditional request
|
||||
// can short-circuit an unchanged repo.
|
||||
func (db *DB) claimSyncLease(ctx context.Context, table, remoteName, owner string, freshness, lease time.Duration) (bool, string, error) {
|
||||
func (db *DB) claimSyncLease(ctx context.Context, table, remoteName, owner string, freshness, lease, interval, phase time.Duration) (bool, string, error) {
|
||||
const slot = `to_timestamp(floor((extract(epoch FROM now())::float8 - $6::float8) / NULLIF($5::float8, 0)) * $5::float8 + $6::float8)`
|
||||
row := db.Pool.QueryRow(ctx, `
|
||||
INSERT INTO `+table+` AS s (remote_name, sync_lease_owner, sync_lease_expires)
|
||||
VALUES ($1, $2, now() + make_interval(secs => $4))
|
||||
SELECT r.name, $2, now() + make_interval(secs => $4)
|
||||
FROM remotes r
|
||||
WHERE r.name = $1
|
||||
AND ($5::float8 <= 0 OR COALESCE(r.created_at, '-infinity') < `+slot+`
|
||||
OR EXISTS (SELECT 1 FROM `+table+` e WHERE e.remote_name = r.name))
|
||||
ON CONFLICT (remote_name) DO UPDATE
|
||||
SET sync_lease_owner = $2,
|
||||
sync_lease_expires = now() + make_interval(secs => $4)
|
||||
WHERE (CASE WHEN s.next_retry_at IS NOT NULL THEN s.next_retry_at <= now()
|
||||
ELSE s.last_synced_at IS NULL OR s.last_synced_at < now() - make_interval(secs => $3) END)
|
||||
ELSE s.last_synced_at IS NULL OR s.last_synced_at < COALESCE(`+slot+`, now() - make_interval(secs => $3)) END)
|
||||
AND (s.sync_lease_expires IS NULL OR s.sync_lease_expires < now())
|
||||
RETURNING s.etag
|
||||
`, remoteName, owner, freshness.Seconds(), lease.Seconds())
|
||||
`, remoteName, owner, freshness.Seconds(), lease.Seconds(), interval.Seconds(), phase.Seconds())
|
||||
|
||||
var etag string
|
||||
if err := row.Scan(&etag); err != nil {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -30,7 +34,7 @@ func TestGitHubSyncLease(t *testing.T) {
|
||||
freshness := time.Hour
|
||||
|
||||
// First claim on a never-synced remote wins; etag starts empty.
|
||||
claimed, etag, err := testDB.ClaimGitHubSyncLease(ctx(), name, "replica-1", freshness, lease)
|
||||
claimed, etag, err := testDB.ClaimGitHubSyncLease(ctx(), name, "replica-1", freshness, lease, 0, 0)
|
||||
if err != nil || !claimed {
|
||||
t.Fatalf("replica-1 first claim: claimed=%v err=%v", claimed, err)
|
||||
}
|
||||
@@ -39,7 +43,7 @@ func TestGitHubSyncLease(t *testing.T) {
|
||||
}
|
||||
|
||||
// A second replica cannot claim while the lease is held.
|
||||
claimed2, _, err := testDB.ClaimGitHubSyncLease(ctx(), name, "replica-2", freshness, lease)
|
||||
claimed2, _, err := testDB.ClaimGitHubSyncLease(ctx(), name, "replica-2", freshness, lease, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("replica-2 claim err: %v", err)
|
||||
}
|
||||
@@ -53,7 +57,7 @@ func TestGitHubSyncLease(t *testing.T) {
|
||||
}
|
||||
|
||||
// A periodic re-claim inside the freshness window is blocked by recency.
|
||||
claimed3, _, err := testDB.ClaimGitHubSyncLease(ctx(), name, "replica-2", freshness, lease)
|
||||
claimed3, _, err := testDB.ClaimGitHubSyncLease(ctx(), name, "replica-2", freshness, lease, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("replica-2 recency claim err: %v", err)
|
||||
}
|
||||
@@ -62,7 +66,7 @@ func TestGitHubSyncLease(t *testing.T) {
|
||||
}
|
||||
|
||||
// A prime (freshness 0) bypasses recency and reads the persisted etag.
|
||||
claimed4, etag4, err := testDB.ClaimGitHubSyncLease(ctx(), name, "replica-2", 0, lease)
|
||||
claimed4, etag4, err := testDB.ClaimGitHubSyncLease(ctx(), name, "replica-2", 0, lease, 0, 0)
|
||||
if err != nil || !claimed4 {
|
||||
t.Fatalf("prime claim: claimed=%v err=%v", claimed4, err)
|
||||
}
|
||||
@@ -108,7 +112,7 @@ func TestGitHubSyncLeaseFailureBackoff(t *testing.T) {
|
||||
freshness := time.Hour
|
||||
claim := func(f time.Duration) (bool, string) {
|
||||
t.Helper()
|
||||
ok, etag, err := testDB.ClaimGitHubSyncLease(ctx(), name, "r1", f, lease)
|
||||
ok, etag, err := testDB.ClaimGitHubSyncLease(ctx(), name, "r1", f, lease, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("claim: %v", err)
|
||||
}
|
||||
@@ -220,21 +224,21 @@ func TestGitHubSyncStateDeletedWithRemote(t *testing.T) {
|
||||
}{
|
||||
{"github_rpm_sync_state", models.PackageGitHubRPM,
|
||||
func(n string) (bool, string, error) {
|
||||
return testDB.ClaimGitHubSyncLease(ctx(), n, "r1", time.Hour, time.Minute)
|
||||
return testDB.ClaimGitHubSyncLease(ctx(), n, "r1", time.Hour, time.Minute, 0, 0)
|
||||
},
|
||||
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)
|
||||
return testDB.ClaimGitHubDebSyncLease(ctx(), n, "r1", time.Hour, time.Minute, 0, 0)
|
||||
},
|
||||
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)
|
||||
return testDB.ClaimGitHubAlpineSyncLease(ctx(), n, "r1", time.Hour, time.Minute, 0, 0)
|
||||
},
|
||||
func(n string, res provider.SyncResult) error {
|
||||
return testDB.ReleaseGitHubAlpineSyncLease(ctx(), n, "r1", res)
|
||||
@@ -276,3 +280,210 @@ func TestGitHubSyncStateDeletedWithRemote(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type syncTable struct {
|
||||
table string
|
||||
pt models.PackageType
|
||||
claim func(name, owner string, freshness, interval, phase time.Duration) (bool, error)
|
||||
}
|
||||
|
||||
func syncTables() []syncTable {
|
||||
wrap := func(fn func(context.Context, string, string, time.Duration, time.Duration, time.Duration, time.Duration) (bool, string, error)) func(string, string, time.Duration, time.Duration, time.Duration) (bool, error) {
|
||||
return func(name, owner string, freshness, interval, phase time.Duration) (bool, error) {
|
||||
ok, _, err := fn(ctx(), name, owner, freshness, 15*time.Minute, interval, phase)
|
||||
return ok, err
|
||||
}
|
||||
}
|
||||
return []syncTable{
|
||||
{"github_rpm_sync_state", models.PackageGitHubRPM, wrap(testDB.ClaimGitHubSyncLease)},
|
||||
{"github_deb_sync_state", models.PackageGitHubDeb, wrap(testDB.ClaimGitHubDebSyncLease)},
|
||||
{"github_alpine_sync_state", models.PackageGitHubAlpine, wrap(testDB.ClaimGitHubAlpineSyncLease)},
|
||||
}
|
||||
}
|
||||
|
||||
var syncSeq atomic.Int64
|
||||
|
||||
// seedSynced creates a remote of tc's type created createdAgo before the DB
|
||||
// clock and, when syncedAgo >= 0, a released lease last synced syncedAgo before it.
|
||||
func seedSynced(t *testing.T, tc syncTable, createdAgo, syncedAgo time.Duration) string {
|
||||
t.Helper()
|
||||
name := fmt.Sprintf("sync-%s-%d", tc.table, syncSeq.Add(1))
|
||||
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",
|
||||
}); err != nil {
|
||||
t.Fatalf("create remote: %v", err)
|
||||
}
|
||||
if _, err := testDB.Pool.Exec(ctx(), `UPDATE remotes SET created_at = now() - make_interval(secs => $2) WHERE name = $1`, name, createdAgo.Seconds()); err != nil {
|
||||
t.Fatalf("seed created_at: %v", err)
|
||||
}
|
||||
if syncedAgo >= 0 {
|
||||
if _, err := testDB.Pool.Exec(ctx(), `INSERT INTO `+tc.table+` (remote_name, last_synced_at) VALUES ($1, now() - make_interval(secs => $2))`, name, syncedAgo.Seconds()); err != nil {
|
||||
t.Fatalf("seed sync state: %v", err)
|
||||
}
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func mustClaim(t *testing.T, tc syncTable, name, owner string, freshness, interval, phase time.Duration) bool {
|
||||
t.Helper()
|
||||
ok, err := tc.claim(name, owner, freshness, interval, phase)
|
||||
if err != nil {
|
||||
t.Fatalf("claim: %v", err)
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
const neverSynced = -1
|
||||
|
||||
// With no interval the claim keeps the freshness-since-last-sync predicate and
|
||||
// a new remote is due at once.
|
||||
func TestSyncLeaseUnsetIntervalFreshness(t *testing.T) {
|
||||
requireDB(t)
|
||||
const ttl = time.Hour
|
||||
for _, tc := range syncTables() {
|
||||
t.Run(tc.table, func(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
ago time.Duration
|
||||
freshness time.Duration
|
||||
due bool
|
||||
}{
|
||||
{neverSynced, ttl, true},
|
||||
{ttl + time.Minute, ttl, true},
|
||||
{ttl - time.Minute, ttl, false},
|
||||
{time.Second, ttl, false},
|
||||
{time.Second, 0, true},
|
||||
} {
|
||||
name := seedSynced(t, tc, time.Second, c.ago)
|
||||
if got := mustClaim(t, tc, name, "r1", c.freshness, 0, 0); got != c.due {
|
||||
t.Errorf("synced %v ago, freshness %v: claimed=%v, want %v", c.ago, c.freshness, got, c.due)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// With an interval the slot comes from the DB clock: a remote is due only when
|
||||
// created and last synced before the current slot, regardless of freshness,
|
||||
// and still one replica at a time.
|
||||
func TestSyncLeaseSlot(t *testing.T) {
|
||||
requireDB(t)
|
||||
var epoch float64
|
||||
if err := testDB.Pool.QueryRow(ctx(), `SELECT extract(epoch FROM now())::float8`).Scan(&epoch); err != nil {
|
||||
t.Fatalf("db now: %v", err)
|
||||
}
|
||||
const interval = time.Hour
|
||||
// Pick the phase that puts the current slot's start 30m before the DB now.
|
||||
phase := time.Duration(math.Mod(epoch-1800, interval.Seconds()) * float64(time.Second))
|
||||
const day = 24 * time.Hour
|
||||
for _, tc := range syncTables() {
|
||||
t.Run(tc.table, func(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
desc string
|
||||
createdAgo, ago time.Duration
|
||||
freshness time.Duration
|
||||
due bool
|
||||
}{
|
||||
{"created inside the slot", 10 * time.Second, neverSynced, 0, false},
|
||||
{"created before the slot", 35 * time.Minute, neverSynced, 0, true},
|
||||
{"synced inside the slot", day, 25 * time.Minute, 0, false},
|
||||
{"synced before the slot", day, 35 * time.Minute, 10 * 365 * day, true},
|
||||
} {
|
||||
name := seedSynced(t, tc, c.createdAgo, c.ago)
|
||||
if got := mustClaim(t, tc, name, "r1", c.freshness, interval, phase); got != c.due {
|
||||
t.Errorf("%s: claimed=%v, want %v", c.desc, got, c.due)
|
||||
}
|
||||
if c.due && mustClaim(t, tc, name, "r2", 0, interval, phase) {
|
||||
t.Errorf("%s: second replica claimed a held lease", c.desc)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// slotPhase returns the phase that puts the current interval slot's start
|
||||
// startAgo before the DB clock.
|
||||
func slotPhase(t *testing.T, interval, startAgo time.Duration) time.Duration {
|
||||
t.Helper()
|
||||
var epoch float64
|
||||
if err := testDB.Pool.QueryRow(ctx(), `SELECT extract(epoch FROM now())::float8`).Scan(&epoch); err != nil {
|
||||
t.Fatalf("db now: %v", err)
|
||||
}
|
||||
return time.Duration(math.Mod(epoch-startAgo.Seconds(), interval.Seconds()) * float64(time.Second))
|
||||
}
|
||||
|
||||
func setSyncedAgo(t *testing.T, tc syncTable, name string, ago time.Duration) {
|
||||
t.Helper()
|
||||
if _, err := testDB.Pool.Exec(ctx(), `UPDATE `+tc.table+` SET last_synced_at = now() - make_interval(secs => $2), sync_lease_owner = '', sync_lease_expires = NULL WHERE remote_name = $1`, name, ago.Seconds()); err != nil {
|
||||
t.Fatalf("set last_synced_at: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Toggling sync_interval on a remote with sync state neither re-syncs at once
|
||||
// nor leaves it waiting longer than one interval (or freshness) since its last
|
||||
// sync. Elapsed time is simulated by moving last_synced_at and the slot start
|
||||
// back together, relative to the DB clock.
|
||||
func TestSyncLeaseIntervalChange(t *testing.T) {
|
||||
requireDB(t)
|
||||
const (
|
||||
interval = time.Hour
|
||||
freshness = time.Hour
|
||||
day = 24 * time.Hour
|
||||
)
|
||||
for _, tc := range syncTables() {
|
||||
t.Run(tc.table, func(t *testing.T) {
|
||||
t.Run("0 to N", func(t *testing.T) {
|
||||
name := seedSynced(t, tc, day, 25*time.Minute)
|
||||
if mustClaim(t, tc, name, "r1", freshness, 0, 0) {
|
||||
t.Fatal("claimed inside freshness before the change")
|
||||
}
|
||||
if mustClaim(t, tc, name, "r1", freshness, interval, slotPhase(t, interval, 30*time.Minute)) {
|
||||
t.Fatal("synced inside the current slot, yet claimed right after the change")
|
||||
}
|
||||
// 30m later the next slot opens; the last sync is 55m old.
|
||||
setSyncedAgo(t, tc, name, 55*time.Minute)
|
||||
if !mustClaim(t, tc, name, "r1", freshness, interval, slotPhase(t, interval, time.Second)) {
|
||||
t.Fatal("not due at the first slot after the last sync")
|
||||
}
|
||||
})
|
||||
t.Run("N to 0", func(t *testing.T) {
|
||||
name := seedSynced(t, tc, day, 10*time.Minute)
|
||||
if mustClaim(t, tc, name, "r1", freshness, interval, slotPhase(t, interval, 30*time.Minute)) {
|
||||
t.Fatal("claimed inside the slot before the change")
|
||||
}
|
||||
if mustClaim(t, tc, name, "r1", freshness, 0, 0) {
|
||||
t.Fatal("synced inside freshness, yet claimed right after the change")
|
||||
}
|
||||
setSyncedAgo(t, tc, name, freshness+time.Second)
|
||||
if !mustClaim(t, tc, name, "r1", freshness, 0, 0) {
|
||||
t.Fatal("not due once freshness elapsed after the change")
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The created_at gate only holds back the first insert of a remote created
|
||||
// inside the current slot; once a state row exists the row's own sync and
|
||||
// retry state decide.
|
||||
func TestSyncLeaseCreatedGateInsertOnly(t *testing.T) {
|
||||
requireDB(t)
|
||||
const interval = time.Hour
|
||||
phase := slotPhase(t, interval, 30*time.Minute)
|
||||
for _, tc := range syncTables() {
|
||||
t.Run(tc.table, func(t *testing.T) {
|
||||
fresh := seedSynced(t, tc, 10*time.Second, neverSynced)
|
||||
if mustClaim(t, tc, fresh, "r1", 0, interval, phase) {
|
||||
t.Fatal("remote created inside the slot claimed with no state row")
|
||||
}
|
||||
|
||||
name := seedSynced(t, tc, 10*time.Second, neverSynced)
|
||||
if _, err := testDB.Pool.Exec(ctx(), `INSERT INTO `+tc.table+` (remote_name, sync_failures, next_retry_at) VALUES ($1, 1, now() - interval '1 second')`, name); err != nil {
|
||||
t.Fatalf("seed failed prime: %v", err)
|
||||
}
|
||||
if !mustClaim(t, tc, name, "r1", 0, interval, phase) {
|
||||
t.Fatal("created_at gate blocked a remote with a state row whose retry is due")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,16 +3,19 @@ package database
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/pkg/models"
|
||||
)
|
||||
|
||||
const remoteCols = `name, package_type, repo_type, base_url, mirrorlist, mirror_strategy, description, username, password,
|
||||
immutable_ttl, mutable_ttl, check_mutable,
|
||||
immutable_ttl, mutable_ttl, check_mutable, sync_interval,
|
||||
patterns, blocklist, mutable_patterns, immutable_patterns,
|
||||
ban_tags_enabled, ban_tags,
|
||||
quarantine_enabled, quarantine_days, stale_on_error,
|
||||
releases_remote, managed_by,
|
||||
upstream_dial_timeout, upstream_tls_timeout, upstream_response_header_timeout,
|
||||
serve_stale, cache_repodata,
|
||||
created_at, updated_at`
|
||||
|
||||
// normalizeMirrorStrategy maps an empty strategy to the round_robin default so
|
||||
@@ -27,12 +30,13 @@ func normalizeMirrorStrategy(s string) string {
|
||||
func scanRemote(scanner interface{ Scan(...any) error }, r *models.Remote) error {
|
||||
return scanner.Scan(
|
||||
&r.Name, &r.PackageType, &r.RepoType, &r.BaseURL, &r.Mirrorlist, &r.MirrorStrategy, &r.Description, &r.Username, &r.Password,
|
||||
&r.ImmutableTTL, &r.MutableTTL, &r.CheckMutable,
|
||||
&r.ImmutableTTL, &r.MutableTTL, &r.CheckMutable, &r.SyncInterval,
|
||||
&r.Patterns, &r.Blocklist, &r.MutablePatterns, &r.ImmutablePatterns,
|
||||
&r.BanTagsEnabled, &r.BanTags,
|
||||
&r.QuarantineEnabled, &r.QuarantineDays, &r.StaleOnError,
|
||||
&r.ReleasesRemote, &r.ManagedBy,
|
||||
&r.UpstreamDialTimeout, &r.UpstreamTLSTimeout, &r.UpstreamResponseHeaderTimeout,
|
||||
&r.ServeStale, &r.CacheRepodata,
|
||||
&r.CreatedAt, &r.UpdatedAt,
|
||||
)
|
||||
}
|
||||
@@ -74,8 +78,8 @@ func (db *DB) CreateRemote(ctx context.Context, r *models.Remote) error {
|
||||
quarantine_enabled, quarantine_days, stale_on_error,
|
||||
releases_remote, managed_by,
|
||||
upstream_dial_timeout, upstream_tls_timeout, upstream_response_header_timeout,
|
||||
mirror_strategy
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26)
|
||||
mirror_strategy, sync_interval, serve_stale, cache_repodata
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29)
|
||||
`,
|
||||
r.Name, r.PackageType, r.RepoType, r.BaseURL, r.Mirrorlist, r.Description, r.Username, r.Password,
|
||||
r.ImmutableTTL, r.MutableTTL, r.CheckMutable,
|
||||
@@ -84,7 +88,7 @@ func (db *DB) CreateRemote(ctx context.Context, r *models.Remote) error {
|
||||
r.QuarantineEnabled, r.QuarantineDays, r.StaleOnError,
|
||||
r.ReleasesRemote, r.ManagedBy,
|
||||
r.UpstreamDialTimeout, r.UpstreamTLSTimeout, r.UpstreamResponseHeaderTimeout,
|
||||
normalizeMirrorStrategy(r.MirrorStrategy),
|
||||
normalizeMirrorStrategy(r.MirrorStrategy), r.SyncInterval, r.ServeStale, r.CacheRepodata,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -99,7 +103,7 @@ func (db *DB) UpdateRemote(ctx context.Context, r *models.Remote) error {
|
||||
quarantine_enabled=$17, quarantine_days=$18, stale_on_error=$19,
|
||||
releases_remote=$20, managed_by=$21,
|
||||
upstream_dial_timeout=$22, upstream_tls_timeout=$23, upstream_response_header_timeout=$24,
|
||||
mirror_strategy=$26,
|
||||
mirror_strategy=$26, sync_interval=$27, serve_stale=$28, cache_repodata=$29,
|
||||
updated_at=NOW()
|
||||
WHERE name=$1
|
||||
`,
|
||||
@@ -111,12 +115,21 @@ func (db *DB) UpdateRemote(ctx context.Context, r *models.Remote) error {
|
||||
r.ReleasesRemote, r.ManagedBy,
|
||||
r.UpstreamDialTimeout, r.UpstreamTLSTimeout, r.UpstreamResponseHeaderTimeout,
|
||||
r.Mirrorlist,
|
||||
normalizeMirrorStrategy(r.MirrorStrategy),
|
||||
normalizeMirrorStrategy(r.MirrorStrategy), r.SyncInterval, r.ServeStale, r.CacheRepodata,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteRemote drops a remote with its local files and package metadata, so a
|
||||
// repo recreated under the same name starts empty.
|
||||
func (db *DB) DeleteRemote(ctx context.Context, name string) error {
|
||||
_, err := db.Pool.Exec(ctx, `DELETE FROM remotes WHERE name = $1`, name)
|
||||
return err
|
||||
return pgx.BeginFunc(ctx, db.Pool, func(tx pgx.Tx) error {
|
||||
for _, table := range []string{"local_files", "rpm_metadata", "deb_metadata", "alpine_metadata", "puppet_metadata", "helm_metadata"} {
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM `+table+` WHERE repo_name = $1`, name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err := tx.Exec(ctx, `DELETE FROM remotes WHERE name = $1`, name)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
@@ -40,6 +40,14 @@ func (db *DB) DeleteRPMMetadata(ctx context.Context, repoName, filePath string)
|
||||
return err
|
||||
}
|
||||
|
||||
// RPMMetadataFingerprint is a cheap change detector for a repo's rows: they are
|
||||
// only ever inserted or deleted, so (count, max id) moves whenever they change.
|
||||
// For github_rpm remotes it relies on the sync lease serialising scans.
|
||||
func (db *DB) RPMMetadataFingerprint(ctx context.Context, repoName string) (count, maxID int64, err error) {
|
||||
err = db.Pool.QueryRow(ctx, `SELECT count(*), coalesce(max(id), 0) FROM rpm_metadata WHERE repo_name = $1`, repoName).Scan(&count, &maxID)
|
||||
return count, maxID, err
|
||||
}
|
||||
|
||||
type RPMMetadataRow struct {
|
||||
RepoName string
|
||||
FilePath string
|
||||
|
||||
@@ -8,12 +8,12 @@ import (
|
||||
|
||||
func (db *DB) GetVirtual(ctx context.Context, name string) (*models.Virtual, error) {
|
||||
row := db.Pool.QueryRow(ctx, `
|
||||
SELECT name, package_type, description, members, managed_by, created_at, updated_at
|
||||
SELECT name, package_type, description, members, managed_by, parallel_fetch, created_at, updated_at
|
||||
FROM virtuals WHERE name = $1
|
||||
`, name)
|
||||
|
||||
var v models.Virtual
|
||||
err := row.Scan(&v.Name, &v.PackageType, &v.Description, &v.Members, &v.ManagedBy, &v.CreatedAt, &v.UpdatedAt)
|
||||
err := row.Scan(&v.Name, &v.PackageType, &v.Description, &v.Members, &v.ManagedBy, &v.ParallelFetch, &v.CreatedAt, &v.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -22,7 +22,7 @@ func (db *DB) GetVirtual(ctx context.Context, name string) (*models.Virtual, err
|
||||
|
||||
func (db *DB) ListVirtuals(ctx context.Context) ([]models.Virtual, error) {
|
||||
rows, err := db.Pool.Query(ctx, `
|
||||
SELECT name, package_type, description, members, managed_by, created_at, updated_at
|
||||
SELECT name, package_type, description, members, managed_by, parallel_fetch, created_at, updated_at
|
||||
FROM virtuals ORDER BY name
|
||||
`)
|
||||
if err != nil {
|
||||
@@ -33,7 +33,7 @@ func (db *DB) ListVirtuals(ctx context.Context) ([]models.Virtual, error) {
|
||||
var virtuals []models.Virtual
|
||||
for rows.Next() {
|
||||
var v models.Virtual
|
||||
if err := rows.Scan(&v.Name, &v.PackageType, &v.Description, &v.Members, &v.ManagedBy, &v.CreatedAt, &v.UpdatedAt); err != nil {
|
||||
if err := rows.Scan(&v.Name, &v.PackageType, &v.Description, &v.Members, &v.ManagedBy, &v.ParallelFetch, &v.CreatedAt, &v.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
virtuals = append(virtuals, v)
|
||||
@@ -43,18 +43,18 @@ func (db *DB) ListVirtuals(ctx context.Context) ([]models.Virtual, error) {
|
||||
|
||||
func (db *DB) CreateVirtual(ctx context.Context, v *models.Virtual) error {
|
||||
_, err := db.Pool.Exec(ctx, `
|
||||
INSERT INTO virtuals (name, package_type, description, members, managed_by)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
`, v.Name, v.PackageType, v.Description, v.Members, v.ManagedBy)
|
||||
INSERT INTO virtuals (name, package_type, description, members, managed_by, parallel_fetch)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
`, v.Name, v.PackageType, v.Description, v.Members, v.ManagedBy, v.ParallelFetch)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) UpdateVirtual(ctx context.Context, v *models.Virtual) error {
|
||||
_, err := db.Pool.Exec(ctx, `
|
||||
UPDATE virtuals SET
|
||||
package_type=$2, description=$3, members=$4, managed_by=$5, updated_at=NOW()
|
||||
package_type=$2, description=$3, members=$4, managed_by=$5, parallel_fetch=$6, updated_at=NOW()
|
||||
WHERE name=$1
|
||||
`, v.Name, v.PackageType, v.Description, v.Members, v.ManagedBy)
|
||||
`, v.Name, v.PackageType, v.Description, v.Members, v.ManagedBy, v.ParallelFetch)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
package all
|
||||
|
||||
import _ "git.unkin.net/unkin/artifactapi/internal/provider/ansible"
|
||||
@@ -0,0 +1,3 @@
|
||||
package all
|
||||
|
||||
import _ "git.unkin.net/unkin/artifactapi/internal/provider/vcs"
|
||||
@@ -27,7 +27,7 @@ const (
|
||||
type SyncStore interface {
|
||||
provider.RemoteMetadataStore
|
||||
ListGitHubAlpineRemotes(ctx context.Context) ([]models.Remote, error)
|
||||
ClaimGitHubAlpineSyncLease(ctx context.Context, remoteName, owner string, freshness, lease time.Duration) (claimed bool, etag string, err error)
|
||||
ClaimGitHubAlpineSyncLease(ctx context.Context, remoteName, owner string, freshness, lease, interval, phase time.Duration) (claimed bool, etag string, err error)
|
||||
ReleaseGitHubAlpineSyncLease(ctx context.Context, remoteName, owner string, res provider.SyncResult) error
|
||||
}
|
||||
|
||||
@@ -129,7 +129,8 @@ func (s *Syncer) Run(ctx context.Context) {
|
||||
}
|
||||
|
||||
// schedule enqueues a periodic check for every github_alpine remote. The DB lease
|
||||
// enforces the per-remote mutable_ttl cadence and cross-replica coordination.
|
||||
// enforces the per-remote sync_interval (else mutable_ttl) cadence and
|
||||
// cross-replica coordination.
|
||||
func (s *Syncer) schedule(ctx context.Context) {
|
||||
remotes, err := s.store.ListGitHubAlpineRemotes(ctx)
|
||||
if err != nil {
|
||||
@@ -142,8 +143,10 @@ func (s *Syncer) schedule(ctx context.Context) {
|
||||
}
|
||||
|
||||
// EnqueuePrime queues an immediate background prime for a freshly created remote.
|
||||
// A sync_interval remote skips it and syncs in its first slot, so remotes
|
||||
// created together do not burst.
|
||||
func (s *Syncer) EnqueuePrime(remote models.Remote) {
|
||||
if s == nil {
|
||||
if s == nil || remote.SyncInterval > 0 {
|
||||
return
|
||||
}
|
||||
s.enqueue(remote, true)
|
||||
@@ -189,16 +192,16 @@ func (s *Syncer) process(ctx context.Context, job syncJob) {
|
||||
s.mu.Unlock()
|
||||
}()
|
||||
|
||||
ttl := time.Duration(job.remote.MutableTTL) * time.Second
|
||||
if ttl <= 0 {
|
||||
ttl = defaultSyncFreshness
|
||||
}
|
||||
ttl := job.remote.SyncPeriod(defaultSyncFreshness)
|
||||
freshness := ttl
|
||||
var interval, phase time.Duration
|
||||
if job.prime {
|
||||
freshness = 0
|
||||
} else {
|
||||
interval, phase = job.remote.SyncSchedule()
|
||||
}
|
||||
|
||||
claimed, etag, err := s.store.ClaimGitHubAlpineSyncLease(ctx, job.remote.Name, s.owner, freshness, syncLeaseDuration)
|
||||
claimed, etag, err := s.store.ClaimGitHubAlpineSyncLease(ctx, job.remote.Name, s.owner, freshness, syncLeaseDuration, interval, phase)
|
||||
if err != nil {
|
||||
slog.Error("github_alpine syncer: claim lease", "remote", job.remote.Name, "error", err)
|
||||
return
|
||||
|
||||
@@ -28,6 +28,10 @@ type fakeSyncStore struct {
|
||||
lastSynced map[string]time.Time
|
||||
etags map[string]string
|
||||
retryAt map[string]time.Time
|
||||
freshness time.Duration
|
||||
interval time.Duration
|
||||
phase time.Duration
|
||||
attempts int
|
||||
}
|
||||
|
||||
func newFakeSyncStore() *fakeSyncStore {
|
||||
@@ -47,9 +51,11 @@ func (f *fakeSyncStore) ListGitHubAlpineRemotes(_ context.Context) ([]models.Rem
|
||||
return append([]models.Remote(nil), f.remotes...), nil
|
||||
}
|
||||
|
||||
func (f *fakeSyncStore) ClaimGitHubAlpineSyncLease(_ context.Context, name, owner string, freshness, lease time.Duration) (bool, string, error) {
|
||||
func (f *fakeSyncStore) ClaimGitHubAlpineSyncLease(_ context.Context, name, owner string, freshness, lease, interval, phase time.Duration) (bool, string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.freshness, f.interval, f.phase = freshness, interval, phase
|
||||
f.attempts++
|
||||
now := time.Now()
|
||||
ls, hasLS := f.lastSynced[name]
|
||||
exp, hasExp := f.leaseExp[name]
|
||||
@@ -223,7 +229,7 @@ func TestSyncerLeasePreventsSecondReplica(t *testing.T) {
|
||||
s := newSyncer(store, p, testSyncConfig())
|
||||
remote := fx.remote()
|
||||
|
||||
claimed, _, err := store.ClaimGitHubAlpineSyncLease(context.Background(), remote.Name, "replica-1", time.Duration(remote.MutableTTL)*time.Second, syncLeaseDuration)
|
||||
claimed, _, err := store.ClaimGitHubAlpineSyncLease(context.Background(), remote.Name, "replica-1", time.Duration(remote.MutableTTL)*time.Second, syncLeaseDuration, 0, 0)
|
||||
if err != nil || !claimed {
|
||||
t.Fatalf("replica-1 claim: claimed=%v err=%v", claimed, err)
|
||||
}
|
||||
@@ -363,3 +369,37 @@ func TestSyncerFailedAssetRetry(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A periodic check claims with sync_interval as its freshness and passes the
|
||||
// remote's slot schedule; unset, it falls back to mutable_ttl with no schedule.
|
||||
func TestSyncerHonoursSyncInterval(t *testing.T) {
|
||||
for _, interval := range []int{0, 7200} {
|
||||
store := newFakeSyncStore()
|
||||
s := newSyncer(store, newTestProvider(), testSyncConfig())
|
||||
store.lastSynced["sched"] = time.Now()
|
||||
remote := models.Remote{Name: "sched", MutableTTL: 90, SyncInterval: interval}
|
||||
wantInterval, wantPhase := remote.SyncSchedule()
|
||||
s.process(context.Background(), syncJob{remote: remote})
|
||||
if want := remote.SyncPeriod(0); store.freshness != want || store.interval != wantInterval || store.phase != wantPhase {
|
||||
t.Errorf("sync_interval=%d: claim freshness/interval/phase = %v/%v/%v, want %v/%v/%v",
|
||||
interval, store.freshness, store.interval, store.phase, want, wantInterval, wantPhase)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create-time primes are skipped for a scheduled remote so remotes created
|
||||
// together spread out; a cold-cache prime still claims with no schedule.
|
||||
func TestSyncerScheduledPrime(t *testing.T) {
|
||||
store := newFakeSyncStore()
|
||||
s := newSyncer(store, newTestProvider(), testSyncConfig())
|
||||
remote := models.Remote{Name: "fresh", SyncInterval: 3600}
|
||||
|
||||
s.EnqueuePrime(remote)
|
||||
if len(s.jobs) != 0 {
|
||||
t.Fatal("create-time prime enqueued for a sync_interval remote")
|
||||
}
|
||||
s.process(context.Background(), syncJob{remote: remote, prime: true})
|
||||
if store.attempts != 1 || store.freshness != 0 || store.interval != 0 {
|
||||
t.Fatalf("cold-cache prime: attempts=%d freshness=%v interval=%v, want 1/0/0", store.attempts, store.freshness, store.interval)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -27,7 +27,7 @@ const (
|
||||
type SyncStore interface {
|
||||
provider.RemoteMetadataStore
|
||||
ListGitHubDebRemotes(ctx context.Context) ([]models.Remote, error)
|
||||
ClaimGitHubDebSyncLease(ctx context.Context, remoteName, owner string, freshness, lease time.Duration) (claimed bool, etag string, err error)
|
||||
ClaimGitHubDebSyncLease(ctx context.Context, remoteName, owner string, freshness, lease, interval, phase time.Duration) (claimed bool, etag string, err error)
|
||||
ReleaseGitHubDebSyncLease(ctx context.Context, remoteName, owner string, res provider.SyncResult) error
|
||||
}
|
||||
|
||||
@@ -129,7 +129,8 @@ func (s *Syncer) Run(ctx context.Context) {
|
||||
}
|
||||
|
||||
// schedule enqueues a periodic check for every github_deb remote. The DB lease
|
||||
// enforces the per-remote mutable_ttl cadence and cross-replica coordination.
|
||||
// enforces the per-remote sync_interval (else mutable_ttl) cadence and
|
||||
// cross-replica coordination.
|
||||
func (s *Syncer) schedule(ctx context.Context) {
|
||||
remotes, err := s.store.ListGitHubDebRemotes(ctx)
|
||||
if err != nil {
|
||||
@@ -142,8 +143,10 @@ func (s *Syncer) schedule(ctx context.Context) {
|
||||
}
|
||||
|
||||
// EnqueuePrime queues an immediate background prime for a freshly created remote.
|
||||
// A sync_interval remote skips it and syncs in its first slot, so remotes
|
||||
// created together do not burst.
|
||||
func (s *Syncer) EnqueuePrime(remote models.Remote) {
|
||||
if s == nil {
|
||||
if s == nil || remote.SyncInterval > 0 {
|
||||
return
|
||||
}
|
||||
s.enqueue(remote, true)
|
||||
@@ -189,16 +192,16 @@ func (s *Syncer) process(ctx context.Context, job syncJob) {
|
||||
s.mu.Unlock()
|
||||
}()
|
||||
|
||||
ttl := time.Duration(job.remote.MutableTTL) * time.Second
|
||||
if ttl <= 0 {
|
||||
ttl = defaultSyncFreshness
|
||||
}
|
||||
ttl := job.remote.SyncPeriod(defaultSyncFreshness)
|
||||
freshness := ttl
|
||||
var interval, phase time.Duration
|
||||
if job.prime {
|
||||
freshness = 0
|
||||
} else {
|
||||
interval, phase = job.remote.SyncSchedule()
|
||||
}
|
||||
|
||||
claimed, etag, err := s.store.ClaimGitHubDebSyncLease(ctx, job.remote.Name, s.owner, freshness, syncLeaseDuration)
|
||||
claimed, etag, err := s.store.ClaimGitHubDebSyncLease(ctx, job.remote.Name, s.owner, freshness, syncLeaseDuration, interval, phase)
|
||||
if err != nil {
|
||||
slog.Error("github_deb syncer: claim lease", "remote", job.remote.Name, "error", err)
|
||||
return
|
||||
|
||||
@@ -28,6 +28,10 @@ type fakeSyncStore struct {
|
||||
lastSynced map[string]time.Time
|
||||
etags map[string]string
|
||||
retryAt map[string]time.Time
|
||||
freshness time.Duration
|
||||
interval time.Duration
|
||||
phase time.Duration
|
||||
attempts int
|
||||
}
|
||||
|
||||
func newFakeSyncStore() *fakeSyncStore {
|
||||
@@ -47,9 +51,11 @@ func (f *fakeSyncStore) ListGitHubDebRemotes(_ context.Context) ([]models.Remote
|
||||
return append([]models.Remote(nil), f.remotes...), nil
|
||||
}
|
||||
|
||||
func (f *fakeSyncStore) ClaimGitHubDebSyncLease(_ context.Context, name, owner string, freshness, lease time.Duration) (bool, string, error) {
|
||||
func (f *fakeSyncStore) ClaimGitHubDebSyncLease(_ context.Context, name, owner string, freshness, lease, interval, phase time.Duration) (bool, string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.freshness, f.interval, f.phase = freshness, interval, phase
|
||||
f.attempts++
|
||||
now := time.Now()
|
||||
ls, hasLS := f.lastSynced[name]
|
||||
exp, hasExp := f.leaseExp[name]
|
||||
@@ -223,7 +229,7 @@ func TestSyncerLeasePreventsSecondReplica(t *testing.T) {
|
||||
s := newSyncer(store, p, testSyncConfig())
|
||||
remote := fx.remote()
|
||||
|
||||
claimed, _, err := store.ClaimGitHubDebSyncLease(context.Background(), remote.Name, "replica-1", time.Duration(remote.MutableTTL)*time.Second, syncLeaseDuration)
|
||||
claimed, _, err := store.ClaimGitHubDebSyncLease(context.Background(), remote.Name, "replica-1", time.Duration(remote.MutableTTL)*time.Second, syncLeaseDuration, 0, 0)
|
||||
if err != nil || !claimed {
|
||||
t.Fatalf("replica-1 claim: claimed=%v err=%v", claimed, err)
|
||||
}
|
||||
@@ -363,3 +369,37 @@ func TestSyncerFailedAssetRetry(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A periodic check claims with sync_interval as its freshness and passes the
|
||||
// remote's slot schedule; unset, it falls back to mutable_ttl with no schedule.
|
||||
func TestSyncerHonoursSyncInterval(t *testing.T) {
|
||||
for _, interval := range []int{0, 7200} {
|
||||
store := newFakeSyncStore()
|
||||
s := newSyncer(store, newTestProvider(), testSyncConfig())
|
||||
store.lastSynced["sched"] = time.Now()
|
||||
remote := models.Remote{Name: "sched", MutableTTL: 90, SyncInterval: interval}
|
||||
wantInterval, wantPhase := remote.SyncSchedule()
|
||||
s.process(context.Background(), syncJob{remote: remote})
|
||||
if want := remote.SyncPeriod(0); store.freshness != want || store.interval != wantInterval || store.phase != wantPhase {
|
||||
t.Errorf("sync_interval=%d: claim freshness/interval/phase = %v/%v/%v, want %v/%v/%v",
|
||||
interval, store.freshness, store.interval, store.phase, want, wantInterval, wantPhase)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create-time primes are skipped for a scheduled remote so remotes created
|
||||
// together spread out; a cold-cache prime still claims with no schedule.
|
||||
func TestSyncerScheduledPrime(t *testing.T) {
|
||||
store := newFakeSyncStore()
|
||||
s := newSyncer(store, newTestProvider(), testSyncConfig())
|
||||
remote := models.Remote{Name: "fresh", SyncInterval: 3600}
|
||||
|
||||
s.EnqueuePrime(remote)
|
||||
if len(s.jobs) != 0 {
|
||||
t.Fatal("create-time prime enqueued for a sync_interval remote")
|
||||
}
|
||||
s.process(context.Background(), syncJob{remote: remote, prime: true})
|
||||
if store.attempts != 1 || store.freshness != 0 || store.interval != 0 {
|
||||
t.Fatalf("cold-cache prime: attempts=%d freshness=%v interval=%v, want 1/0/0", store.attempts, store.freshness, store.interval)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ func (p *Provider) AuthHeaders(ctx context.Context, remote models.Remote) (http.
|
||||
h.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(remote.Username+":"+remote.Password)))
|
||||
return h, nil
|
||||
}
|
||||
if isGitHubHost(remote.BaseURL) {
|
||||
if IsGitHubHost(remote.BaseURL) {
|
||||
if c := githubauth.Server(); c != nil {
|
||||
tok, err := c.Token(ctx)
|
||||
if err != nil {
|
||||
@@ -85,11 +85,11 @@ func (p *Provider) AuthHeaders(ctx context.Context, remote models.Remote) (http.
|
||||
return h, nil
|
||||
}
|
||||
|
||||
// isGitHubHost reports whether rawURL targets a GitHub API/download host that
|
||||
// IsGitHubHost reports whether rawURL targets a GitHub API/download host that
|
||||
// accepts the server credential. objects.githubusercontent.com is deliberately
|
||||
// excluded: release-asset downloads 302-redirect there with a pre-signed URL
|
||||
// that must not carry an Authorization header.
|
||||
func isGitHubHost(rawURL string) bool {
|
||||
func IsGitHubHost(rawURL string) bool {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return false
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -29,7 +29,7 @@ const filesPrefix = "v3/files/"
|
||||
// tarball basename that `puppet module build` produces.
|
||||
var releaseRe = regexp.MustCompile(`^([a-z0-9]+)-([a-z][a-z0-9_]*)-(\d+\.\d+\.\d+(?:-[0-9a-z.-]+)?(?:\+[0-9a-z.-]+)?)$`)
|
||||
|
||||
func parseRelease(s string) (slug, version string, ok bool) {
|
||||
func ParseRelease(s string) (slug, version string, ok bool) {
|
||||
m := releaseRe.FindStringSubmatch(strings.ToLower(s))
|
||||
if m == nil {
|
||||
return "", "", false
|
||||
@@ -37,7 +37,12 @@ func parseRelease(s string) (slug, version string, ok bool) {
|
||||
return m[1] + "-" + m[2], m[3], true
|
||||
}
|
||||
|
||||
func normalizeSlug(s string) string {
|
||||
var slugRe = regexp.MustCompile(`^[a-z0-9]+-[a-z][a-z0-9_]*$`)
|
||||
|
||||
// ValidSlug reports whether s is a normalized <owner>-<name> module slug.
|
||||
func ValidSlug(s string) bool { return slugRe.MatchString(s) }
|
||||
|
||||
func NormalizeSlug(s string) string {
|
||||
return strings.ToLower(strings.ReplaceAll(s, "/", "-"))
|
||||
}
|
||||
|
||||
@@ -45,7 +50,7 @@ func normalizeSlug(s string) string {
|
||||
// resolve straight to the stored file.
|
||||
func (p *Provider) ValidateUpload(filePath string) (storagePath, contentType string, err error) {
|
||||
filename := strings.ToLower(path.Base(path.Clean("/" + filePath)))
|
||||
if _, _, ok := parseRelease(strings.TrimSuffix(filename, ".tar.gz")); !ok || !strings.HasSuffix(filename, ".tar.gz") {
|
||||
if _, _, ok := ParseRelease(strings.TrimSuffix(filename, ".tar.gz")); !ok || !strings.HasSuffix(filename, ".tar.gz") {
|
||||
return "", "", fmt.Errorf("file must be named <owner>-<module>-<version>.tar.gz")
|
||||
}
|
||||
return filesPrefix + filename, "application/gzip", nil
|
||||
@@ -82,7 +87,7 @@ func (p *Provider) AfterUpload(ctx context.Context, repoName, storagePath, conte
|
||||
slog.Error("puppet metadata: read failed", "repo", repoName, "path", storagePath, "error", err)
|
||||
return
|
||||
}
|
||||
slug, version, _ := parseRelease(strings.TrimSuffix(path.Base(storagePath), ".tar.gz"))
|
||||
slug, version, _ := ParseRelease(strings.TrimSuffix(path.Base(storagePath), ".tar.gz"))
|
||||
if meta.Slug != slug || meta.Version != version {
|
||||
slog.Error("puppet metadata: metadata.json does not match filename", "repo", repoName, "path", storagePath, "name", meta.Slug, "version", meta.Version)
|
||||
return
|
||||
@@ -158,7 +163,7 @@ func parseModule(r io.Reader) (*provider.PuppetMetadata, error) {
|
||||
if md.Name == "" || md.Version == "" {
|
||||
return nil, errors.New("metadata.json missing name or version")
|
||||
}
|
||||
return &provider.PuppetMetadata{Slug: normalizeSlug(md.Name), Version: md.Version, MetadataJSON: string(body)}, nil
|
||||
return &provider.PuppetMetadata{Slug: NormalizeSlug(md.Name), Version: md.Version, MetadataJSON: string(body)}, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,9 +186,9 @@ func (p *Provider) ServeLocalIndex(w http.ResponseWriter, r *http.Request, files
|
||||
case clean == "v3/releases":
|
||||
serveReleaseList(w, r, list)
|
||||
case strings.HasPrefix(clean, "v3/releases/"):
|
||||
slug, version, ok := parseRelease(strings.TrimPrefix(clean, "v3/releases/"))
|
||||
slug, version, ok := ParseRelease(strings.TrimPrefix(clean, "v3/releases/"))
|
||||
if !ok {
|
||||
notFound(w)
|
||||
NotFound(w)
|
||||
return true
|
||||
}
|
||||
metas, err := list(slug)
|
||||
@@ -193,23 +198,23 @@ func (p *Provider) ServeLocalIndex(w http.ResponseWriter, r *http.Request, files
|
||||
}
|
||||
for _, m := range metas {
|
||||
if m.Version == version {
|
||||
writeJSON(w, releaseObject(m))
|
||||
WriteJSON(w, releaseObject(m))
|
||||
return true
|
||||
}
|
||||
}
|
||||
notFound(w)
|
||||
NotFound(w)
|
||||
case strings.HasPrefix(clean, "v3/modules/"):
|
||||
slug := normalizeSlug(strings.TrimPrefix(clean, "v3/modules/"))
|
||||
slug := NormalizeSlug(strings.TrimPrefix(clean, "v3/modules/"))
|
||||
metas, err := list(slug)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return true
|
||||
}
|
||||
if len(metas) == 0 {
|
||||
notFound(w)
|
||||
NotFound(w)
|
||||
return true
|
||||
}
|
||||
writeJSON(w, moduleObject(metas))
|
||||
WriteJSON(w, moduleObject(metas))
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@@ -222,7 +227,22 @@ func (p *Provider) GenerateLocalIndex(ctx context.Context, files provider.FileSt
|
||||
|
||||
func serveReleaseList(w http.ResponseWriter, r *http.Request, list func(string) ([]provider.PuppetMetadata, error)) {
|
||||
q := r.URL.Query()
|
||||
slug := normalizeSlug(q.Get("module"))
|
||||
slug := NormalizeSlug(q.Get("module"))
|
||||
metas, err := list(slug)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
releases := make([]any, len(metas))
|
||||
for i, m := range metas {
|
||||
releases[i] = releaseObject(m)
|
||||
}
|
||||
WriteJSON(w, ReleasesPage(slug, releases, q))
|
||||
}
|
||||
|
||||
// ReleasesPage is one limit/offset page of a /v3/releases listing, with
|
||||
// pagination links relative to the Forge root.
|
||||
func ReleasesPage(slug string, releases []any, q url.Values) map[string]any {
|
||||
limit, err := strconv.Atoi(q.Get("limit"))
|
||||
if err != nil || limit <= 0 {
|
||||
limit = 20
|
||||
@@ -232,20 +252,10 @@ func serveReleaseList(w http.ResponseWriter, r *http.Request, list func(string)
|
||||
if err != nil || offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
metas, err := list(slug)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
offset = min(offset, len(metas))
|
||||
results := []map[string]any{}
|
||||
for _, m := range metas[offset : offset+min(limit, len(metas)-offset)] {
|
||||
results = append(results, releaseObject(m))
|
||||
}
|
||||
offset = min(offset, len(releases))
|
||||
|
||||
page := func(off int) any {
|
||||
if off < 0 || off >= len(metas) && off != 0 {
|
||||
if off < 0 || off >= len(releases) && off != 0 {
|
||||
return nil
|
||||
}
|
||||
v := url.Values{"limit": {strconv.Itoa(limit)}, "offset": {strconv.Itoa(off)}}
|
||||
@@ -258,7 +268,7 @@ func serveReleaseList(w http.ResponseWriter, r *http.Request, list func(string)
|
||||
if offset > 0 {
|
||||
previous = page(max(offset-limit, 0))
|
||||
}
|
||||
writeJSON(w, map[string]any{
|
||||
return map[string]any{
|
||||
"pagination": map[string]any{
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
@@ -266,10 +276,10 @@ func serveReleaseList(w http.ResponseWriter, r *http.Request, list func(string)
|
||||
"previous": previous,
|
||||
"current": page(offset),
|
||||
"next": page(offset + limit),
|
||||
"total": len(metas),
|
||||
"total": len(releases),
|
||||
},
|
||||
"results": results,
|
||||
})
|
||||
"results": releases[offset : offset+min(limit, len(releases)-offset)],
|
||||
}
|
||||
}
|
||||
|
||||
func owner(slug string) map[string]any {
|
||||
@@ -329,7 +339,7 @@ func moduleObject(metas []provider.PuppetMetadata) map[string]any {
|
||||
return obj
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v any) {
|
||||
func WriteJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
enc := json.NewEncoder(w)
|
||||
@@ -337,7 +347,7 @@ func writeJSON(w http.ResponseWriter, v any) {
|
||||
_ = enc.Encode(v)
|
||||
}
|
||||
|
||||
func notFound(w http.ResponseWriter) {
|
||||
func NotFound(w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = io.WriteString(w, `{"message":"404 Not Found","errors":["The requested resource could not be found"]}`)
|
||||
@@ -349,11 +359,11 @@ func sortReleases(metas []provider.PuppetMetadata) {
|
||||
if c := strings.Compare(a.Slug, b.Slug); c != 0 {
|
||||
return c
|
||||
}
|
||||
return compareSemver(b.Version, a.Version)
|
||||
return CompareSemver(b.Version, a.Version)
|
||||
})
|
||||
}
|
||||
|
||||
func compareSemver(a, b string) int {
|
||||
func CompareSemver(a, b string) int {
|
||||
a, _, _ = strings.Cut(a, "+")
|
||||
b, _, _ = strings.Cut(b, "+")
|
||||
aCore, aPre, aHasPre := strings.Cut(a, "-")
|
||||
|
||||
@@ -291,11 +291,11 @@ func TestFilesFallThrough(t *testing.T) {
|
||||
func TestCompareSemver(t *testing.T) {
|
||||
ordered := []string{"0.1.0", "0.2.0", "0.10.0", "1.0.0-alpha", "1.0.0-alpha.1", "1.0.0-beta", "1.0.0-beta.2", "1.0.0-beta.11", "1.0.0", "1.0.1+build"}
|
||||
for i := 1; i < len(ordered); i++ {
|
||||
if compareSemver(ordered[i-1], ordered[i]) >= 0 || compareSemver(ordered[i], ordered[i-1]) <= 0 {
|
||||
if CompareSemver(ordered[i-1], ordered[i]) >= 0 || CompareSemver(ordered[i], ordered[i-1]) <= 0 {
|
||||
t.Errorf("%s should sort before %s", ordered[i-1], ordered[i])
|
||||
}
|
||||
}
|
||||
if compareSemver("1.0.0+a", "1.0.0+b") != 0 {
|
||||
if CompareSemver("1.0.0+a", "1.0.0+b") != 0 {
|
||||
t.Error("build metadata must not affect precedence")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package puppet
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/auth"
|
||||
@@ -39,6 +40,32 @@ func (p *Provider) UpstreamURL(remote models.Remote, path string) string {
|
||||
return strings.TrimRight(remote.BaseURL, "/") + "/" + strings.TrimLeft(path, "/")
|
||||
}
|
||||
|
||||
// ForwardedQuery keeps the module filter and pagination on release listings.
|
||||
func (p *Provider) ForwardedQuery(path string) map[string]provider.QueryValidator {
|
||||
if path != "v3/releases" {
|
||||
return nil
|
||||
}
|
||||
return map[string]provider.QueryValidator{"module": moduleParam, "limit": pageLimit, "offset": pageOffset}
|
||||
}
|
||||
|
||||
func moduleParam(v string) (string, bool) {
|
||||
v = NormalizeSlug(v)
|
||||
return v, ValidSlug(v)
|
||||
}
|
||||
|
||||
func pageLimit(v string) (string, bool) {
|
||||
n, err := strconv.ParseUint(v, 10, 32)
|
||||
if err != nil || n == 0 {
|
||||
return "", false
|
||||
}
|
||||
return strconv.FormatUint(min(n, 100), 10), true
|
||||
}
|
||||
|
||||
func pageOffset(v string) (string, bool) {
|
||||
n, err := strconv.ParseUint(v, 10, 32)
|
||||
return strconv.FormatUint(n, 10), err == nil
|
||||
}
|
||||
|
||||
func (p *Provider) RewritesResponse() {}
|
||||
|
||||
func (p *Provider) RewriteResponse(body []byte, remote models.Remote, proxyBaseURL string) ([]byte, error) {
|
||||
|
||||
@@ -76,3 +76,28 @@ func TestAuthHeaders(t *testing.T) {
|
||||
t.Error("no credentials, no header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardedQuery(t *testing.T) {
|
||||
p := &Provider{}
|
||||
if p.ForwardedQuery("v3/files/a-b-1.0.0.tar.gz") != nil || p.ForwardedQuery("v3/modules/a-b") != nil {
|
||||
t.Fatal("only release listings forward a query")
|
||||
}
|
||||
f := p.ForwardedQuery("v3/releases")
|
||||
for name, c := range map[string]struct {
|
||||
in, want string
|
||||
ok bool
|
||||
}{
|
||||
"module": {"Puppetlabs/stdlib", "puppetlabs-stdlib", true},
|
||||
"limit": {"500", "100", true},
|
||||
"offset": {"40", "40", true},
|
||||
} {
|
||||
if got, ok := f[name](c.in); got != c.want || ok != c.ok {
|
||||
t.Errorf("%s(%q) = %q %v, want %q %v", name, c.in, got, ok, c.want, c.ok)
|
||||
}
|
||||
}
|
||||
for name, bad := range map[string]string{"module": "../x", "limit": "0", "offset": "-1"} {
|
||||
if _, ok := f[name](bad); ok {
|
||||
t.Errorf("%s(%q) accepted", name, bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,9 +15,11 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
rpmlib "github.com/cavaliergopher/rpm"
|
||||
"golang.org/x/sync/singleflight"
|
||||
"golang.org/x/time/rate"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/githubauth"
|
||||
@@ -52,6 +54,9 @@ const (
|
||||
// retryable 503. Kept short so a client never hangs on a rate-limited derive
|
||||
// of a large repo; small repos usually prime within this window.
|
||||
defaultColdWait = 8 * time.Second
|
||||
// repodataIdleTTL evicts cached indexes of remotes deleted or switched off.
|
||||
repodataIdleTTL = time.Hour
|
||||
repodataSweepEvery = time.Minute
|
||||
)
|
||||
|
||||
// GitHubProvider is a metadata-only remote: it scans a GitHub repo's releases
|
||||
@@ -84,6 +89,26 @@ type GitHubProvider struct {
|
||||
mu sync.Mutex
|
||||
scanning map[string]bool
|
||||
lastScan map[string]time.Time
|
||||
|
||||
// repodata caches generated indexes for cache_repodata remotes, keyed by remote name.
|
||||
repodataMu sync.Mutex
|
||||
repodataCache map[string]cachedRepodata
|
||||
repodataGroup singleflight.Group
|
||||
repodataSeq atomic.Int64
|
||||
repodataSwept time.Time
|
||||
}
|
||||
|
||||
// rpmFingerprinter is a cheap change detector over a remote's metadata rows.
|
||||
// Rows are insert-or-delete only, so (count, max id) changes whenever they do.
|
||||
type rpmFingerprinter interface {
|
||||
RPMMetadataFingerprint(ctx context.Context, repoName string) (count, maxID int64, err error)
|
||||
}
|
||||
|
||||
type cachedRepodata struct {
|
||||
count, maxID int64
|
||||
seq int64 // build order, so a slow older build never replaces a newer one
|
||||
used time.Time
|
||||
*repodata
|
||||
}
|
||||
|
||||
func newGitHubProvider() *GitHubProvider {
|
||||
@@ -97,6 +122,7 @@ func newGitHubProvider() *GitHubProvider {
|
||||
coldWait: defaultColdWait,
|
||||
scanning: map[string]bool{},
|
||||
lastScan: map[string]time.Time{},
|
||||
repodataCache: map[string]cachedRepodata{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,6 +196,11 @@ func (p *GitHubProvider) ServeRemote(w http.ResponseWriter, r *http.Request, rem
|
||||
}
|
||||
|
||||
tail := strings.TrimPrefix(path, "repodata/")
|
||||
if fp, ok := store.(rpmFingerprinter); ok && remote.CacheRepodata {
|
||||
p.serveCachedRepodata(w, sr, store, fp, remote.Name, tail)
|
||||
return true
|
||||
}
|
||||
p.dropRepodata(remote.Name)
|
||||
lp := &Provider{}
|
||||
switch {
|
||||
case tail == "repomd.xml":
|
||||
@@ -239,7 +270,104 @@ func (p *GitHubProvider) ensurePrimed(ctx context.Context, remote models.Remote,
|
||||
return false
|
||||
}
|
||||
|
||||
// serveCachedRepodata serves a file from the generated index, rebuilding it
|
||||
// only when the rows' fingerprint moved. The fingerprint is read from the shared
|
||||
// database on every request, so each replica rebuilds as soon as any replica's
|
||||
// scan changes rows and repomd never outlives the files it references.
|
||||
func (p *GitHubProvider) serveCachedRepodata(w http.ResponseWriter, r *http.Request, store provider.RPMMetadataReader, fp rpmFingerprinter, name, tail string) {
|
||||
var pick func(*repodata) []byte
|
||||
contentType := "application/gzip"
|
||||
switch {
|
||||
case tail == "repomd.xml":
|
||||
pick, contentType = func(rd *repodata) []byte { return rd.repomd }, "application/xml"
|
||||
case strings.HasSuffix(tail, "-primary.xml.gz"):
|
||||
pick = func(rd *repodata) []byte { return rd.primary }
|
||||
case strings.HasSuffix(tail, "-filelists.xml.gz"):
|
||||
pick = func(rd *repodata) []byte { return rd.filelists }
|
||||
case strings.HasSuffix(tail, "-other.xml.gz"):
|
||||
pick = func(rd *repodata) []byte { return rd.other }
|
||||
default:
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
rd, err := p.cachedRepodata(r.Context(), store, fp, name)
|
||||
if err != nil {
|
||||
writeMetadataError(w, name, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(pick(rd))
|
||||
}
|
||||
|
||||
// cachedRepodata returns the index for the current fingerprint, building it at
|
||||
// most once per fingerprint. ctx is ServeRemote's detached serve context, so a
|
||||
// leader's client disconnect cannot fail the build for coalesced waiters.
|
||||
func (p *GitHubProvider) cachedRepodata(ctx context.Context, store provider.RPMMetadataReader, fp rpmFingerprinter, name string) (*repodata, error) {
|
||||
seq := p.repodataSeq.Add(1)
|
||||
count, maxID, err := fp.RPMMetadataFingerprint(ctx, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now()
|
||||
p.repodataMu.Lock()
|
||||
p.sweepRepodataLocked(now)
|
||||
c, ok := p.repodataCache[name]
|
||||
if ok {
|
||||
c.used = now
|
||||
p.repodataCache[name] = c
|
||||
}
|
||||
p.repodataMu.Unlock()
|
||||
if ok && c.count == count && c.maxID == maxID {
|
||||
return c.repodata, nil
|
||||
}
|
||||
|
||||
v, err, _ := p.repodataGroup.Do(fmt.Sprintf("%s\x00%d\x00%d", name, count, maxID), func() (any, error) {
|
||||
metas, err := store.ListRPMMetadataEntries(ctx, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Rows read after the fingerprint can only be newer than it, so the
|
||||
// worst case is one extra rebuild on the next request.
|
||||
rd := buildRepodata(metas)
|
||||
p.repodataMu.Lock()
|
||||
if cur, ok := p.repodataCache[name]; !ok || cur.seq < seq {
|
||||
p.repodataCache[name] = cachedRepodata{count: count, maxID: maxID, seq: seq, used: time.Now(), repodata: rd}
|
||||
}
|
||||
p.repodataMu.Unlock()
|
||||
return rd, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return v.(*repodata), nil
|
||||
}
|
||||
|
||||
func (p *GitHubProvider) dropRepodata(name string) {
|
||||
p.repodataMu.Lock()
|
||||
delete(p.repodataCache, name)
|
||||
p.sweepRepodataLocked(time.Now())
|
||||
p.repodataMu.Unlock()
|
||||
}
|
||||
|
||||
// sweepRepodataLocked evicts idle entries, at most once per repodataSweepEvery.
|
||||
func (p *GitHubProvider) sweepRepodataLocked(now time.Time) {
|
||||
if now.Sub(p.repodataSwept) < repodataSweepEvery {
|
||||
return
|
||||
}
|
||||
p.repodataSwept = now
|
||||
for name, c := range p.repodataCache {
|
||||
if now.Sub(c.used) > repodataIdleTTL {
|
||||
delete(p.repodataCache, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *GitHubProvider) cacheEmpty(ctx context.Context, store provider.RemoteMetadataStore, name string) bool {
|
||||
if fp, ok := store.(rpmFingerprinter); ok {
|
||||
count, _, err := fp.RPMMetadataFingerprint(ctx, name)
|
||||
return err == nil && count == 0
|
||||
}
|
||||
rows, err := store.ListRPMMetadataEntries(ctx, name)
|
||||
if err != nil {
|
||||
// Treat a failed read as "not empty" so a transient DB error becomes a
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
package rpm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/provider"
|
||||
"git.unkin.net/unkin/artifactapi/pkg/models"
|
||||
)
|
||||
|
||||
// fpStore adds a fingerprint and a full-read counter to fakeStore.
|
||||
type fpStore struct {
|
||||
*fakeStore
|
||||
lists, fps int
|
||||
seq int64
|
||||
fpErr, listErr error
|
||||
gate chan struct{} // when set, full reads block until it is closed
|
||||
}
|
||||
|
||||
func (s *fpStore) ListRPMMetadataEntries(ctx context.Context, name string) ([]provider.RPMMetadata, error) {
|
||||
s.mu.Lock()
|
||||
s.lists++
|
||||
gate, err := s.gate, s.listErr
|
||||
s.mu.Unlock()
|
||||
if gate != nil {
|
||||
<-gate
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.fakeStore.ListRPMMetadataEntries(ctx, name)
|
||||
}
|
||||
|
||||
func (s *fpStore) InsertRPMMetadata(ctx context.Context, m *provider.RPMMetadata) error {
|
||||
s.mu.Lock()
|
||||
_, exists := s.rows[m.FilePath]
|
||||
s.mu.Unlock()
|
||||
if err := s.fakeStore.InsertRPMMetadata(ctx, m); err != nil || exists {
|
||||
return err // ON CONFLICT DO NOTHING leaves max(id) unchanged
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.seq++
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fpStore) RPMMetadataFingerprint(_ context.Context, _ string) (int64, int64, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.fps++
|
||||
return int64(len(s.rows)), s.seq, s.fpErr
|
||||
}
|
||||
|
||||
func (s *fpStore) fpCount() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.fps
|
||||
}
|
||||
|
||||
func (s *fpStore) listCount() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.lists
|
||||
}
|
||||
|
||||
func newCacheFixture(t *testing.T, cache bool) (*GitHubProvider, *fpStore, models.Remote) {
|
||||
t.Helper()
|
||||
remote := models.Remote{Name: "cached-rpm", PackageType: models.PackageGitHubRPM, MutableTTL: 3600, CacheRepodata: cache}
|
||||
p := newGitHubProvider()
|
||||
p.lastScan[remote.Name] = time.Now()
|
||||
store := &fpStore{fakeStore: newFakeStore()}
|
||||
addPkg(t, store, "alpha")
|
||||
return p, store, remote
|
||||
}
|
||||
|
||||
func addPkg(t *testing.T, store *fpStore, name string) {
|
||||
t.Helper()
|
||||
if err := store.InsertRPMMetadata(context.Background(), &provider.RPMMetadata{
|
||||
RepoName: "cached-rpm", FilePath: name + "-1-1.noarch.rpm", ContentHash: "sha256:" + name,
|
||||
Name: name, Version: "1", Release: "1", Arch: "noarch", Group: "Unspecified",
|
||||
Files: []provider.RPMFile{{Path: "/usr/bin/" + name}},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func serveReq(t *testing.T, p *GitHubProvider, remote models.Remote, store *fpStore, req *http.Request, path string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
if !p.ServeRemote(rec, req, remote, path, "https://proxy.example", store) {
|
||||
t.Errorf("%s not handled", path)
|
||||
}
|
||||
return rec
|
||||
}
|
||||
|
||||
func serve(t *testing.T, p *GitHubProvider, remote models.Remote, store *fpStore, path string) []byte {
|
||||
t.Helper()
|
||||
rec := serveReq(t, p, remote, store, httptest.NewRequest(http.MethodGet, "/x", nil), path)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s: code %d: %s", path, rec.Code, rec.Body.String())
|
||||
}
|
||||
return rec.Body.Bytes()
|
||||
}
|
||||
|
||||
var repodataPaths = []string{
|
||||
"repodata/repomd.xml",
|
||||
"repodata/x-primary.xml.gz",
|
||||
"repodata/x-filelists.xml.gz",
|
||||
"repodata/x-other.xml.gz",
|
||||
}
|
||||
|
||||
func TestCachedRepodataHitSkipsRegeneration(t *testing.T) {
|
||||
p, store, remote := newCacheFixture(t, true)
|
||||
|
||||
repomd := serve(t, p, remote, store, "repodata/repomd.xml")
|
||||
primary := serve(t, p, remote, store, "repodata/x-primary.xml.gz")
|
||||
if n := store.listCount(); n != 1 {
|
||||
t.Fatalf("full metadata reads = %d, want 1", n)
|
||||
}
|
||||
if !strings.Contains(string(repomd), sha256Hex(primary)+"-primary.xml.gz") {
|
||||
t.Fatalf("cached repomd does not reference cached primary:\n%s", repomd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCachedRepodataRebuildsOnDataChange(t *testing.T) {
|
||||
p, store, remote := newCacheFixture(t, true)
|
||||
before := serve(t, p, remote, store, "repodata/repomd.xml")
|
||||
|
||||
addPkg(t, store, "beta")
|
||||
after := serve(t, p, remote, store, "repodata/repomd.xml")
|
||||
primary := serve(t, p, remote, store, "repodata/x-primary.xml.gz")
|
||||
|
||||
if string(before) == string(after) {
|
||||
t.Fatal("repomd unchanged after metadata change")
|
||||
}
|
||||
if !strings.Contains(string(after), sha256Hex(primary)+"-primary.xml.gz") {
|
||||
t.Fatal("rebuilt repomd does not reference rebuilt primary")
|
||||
}
|
||||
if !strings.Contains(gunzip(t, primary), "<name>beta</name>") {
|
||||
t.Fatal("rebuilt primary missing new package")
|
||||
}
|
||||
if n := store.listCount(); n != 2 {
|
||||
t.Fatalf("full metadata reads = %d, want 2", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUncachedRepodataMatchesCachedAndReadsEveryTime(t *testing.T) {
|
||||
p, store, remote := newCacheFixture(t, true)
|
||||
cached := map[string]string{}
|
||||
for _, path := range repodataPaths {
|
||||
cached[path] = string(serve(t, p, remote, store, path))
|
||||
}
|
||||
|
||||
remote.CacheRepodata = false
|
||||
reads := store.listCount()
|
||||
for _, path := range repodataPaths {
|
||||
if got := string(serve(t, p, remote, store, path)); got != cached[path] {
|
||||
t.Fatalf("%s differs between cached and uncached", path)
|
||||
}
|
||||
}
|
||||
if n := store.listCount() - reads; n != len(repodataPaths) {
|
||||
t.Fatalf("uncached full reads = %d, want %d", n, len(repodataPaths))
|
||||
}
|
||||
if _, ok := p.repodataCache[remote.Name]; ok {
|
||||
t.Fatal("disabling cache_repodata should drop the cached index")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCachedRepodataRebuildsOnSameCountReinsert(t *testing.T) {
|
||||
p, store, remote := newCacheFixture(t, true)
|
||||
addPkg(t, store, "alpha") // conflicting insert: fingerprint must not move
|
||||
serve(t, p, remote, store, "repodata/repomd.xml")
|
||||
serve(t, p, remote, store, "repodata/repomd.xml")
|
||||
if n := store.listCount(); n != 1 {
|
||||
t.Fatalf("full reads after conflicting insert = %d, want 1", n)
|
||||
}
|
||||
|
||||
if err := store.DeleteRPMMetadata(context.Background(), remote.Name, "alpha-1-1.noarch.rpm"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
addPkg(t, store, "alpha")
|
||||
serve(t, p, remote, store, "repodata/repomd.xml")
|
||||
if n := store.listCount(); n != 2 {
|
||||
t.Fatalf("full reads after same-count reinsert = %d, want 2", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCachedRepodataEvictsIdleRemotes(t *testing.T) {
|
||||
p, store, remote := newCacheFixture(t, true)
|
||||
serve(t, p, remote, store, "repodata/repomd.xml")
|
||||
p.repodataCache["gone"] = cachedRepodata{used: time.Now().Add(-2 * repodataIdleTTL), repodata: &repodata{}}
|
||||
p.repodataSwept = time.Time{}
|
||||
|
||||
serve(t, p, remote, store, "repodata/repomd.xml")
|
||||
if _, ok := p.repodataCache["gone"]; ok {
|
||||
t.Fatal("idle remote's index not evicted")
|
||||
}
|
||||
if _, ok := p.repodataCache[remote.Name]; !ok {
|
||||
t.Fatal("active remote's index evicted")
|
||||
}
|
||||
}
|
||||
|
||||
func waitFor(t *testing.T, cond func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for !cond() {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("condition not reached")
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// sfWaiters counts goroutines parked on an in-flight singleflight call.
|
||||
func sfWaiters() int {
|
||||
buf := make([]byte, 1<<20)
|
||||
for {
|
||||
n := runtime.Stack(buf, true)
|
||||
if n < len(buf) {
|
||||
buf = buf[:n]
|
||||
break
|
||||
}
|
||||
buf = make([]byte, 2*len(buf))
|
||||
}
|
||||
waiters := 0
|
||||
for _, g := range strings.Split(string(buf), "\n\n") {
|
||||
if strings.Contains(g, "sync.(*WaitGroup).Wait") && strings.Contains(g, "singleflight.(*Group).Do(") {
|
||||
waiters++
|
||||
}
|
||||
}
|
||||
return waiters
|
||||
}
|
||||
|
||||
func TestCachedRepodataCoalescesConcurrentBuilds(t *testing.T) {
|
||||
p, store, remote := newCacheFixture(t, true)
|
||||
gate := make(chan struct{})
|
||||
store.gate = gate
|
||||
const n = 8
|
||||
var wg sync.WaitGroup
|
||||
codes := make([]int, n)
|
||||
for i := range n {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
codes[i] = serveReq(t, p, remote, store, httptest.NewRequest(http.MethodGet, "/x", nil), repodataPaths[i%len(repodataPaths)]).Code
|
||||
}()
|
||||
}
|
||||
waitFor(t, func() bool { return store.listCount() == 1 && sfWaiters() == n-1 })
|
||||
close(gate)
|
||||
wg.Wait()
|
||||
for i, c := range codes {
|
||||
if c != http.StatusOK {
|
||||
t.Errorf("request %d: code %d", i, c)
|
||||
}
|
||||
}
|
||||
if got := store.listCount(); got != 1 {
|
||||
t.Fatalf("full metadata reads = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCachedRepodataLeaderCancelDoesNotFailWaiters(t *testing.T) {
|
||||
p, store, remote := newCacheFixture(t, true)
|
||||
gate := make(chan struct{})
|
||||
store.gate = gate
|
||||
leaderCtx, cancelLeader := context.WithCancel(context.Background())
|
||||
results := make(chan int, 2)
|
||||
go func() {
|
||||
req := httptest.NewRequest(http.MethodGet, "/x", nil).WithContext(leaderCtx)
|
||||
results <- serveReq(t, p, remote, store, req, "repodata/repomd.xml").Code
|
||||
}()
|
||||
waitFor(t, func() bool { return store.listCount() == 1 })
|
||||
go func() {
|
||||
results <- serveReq(t, p, remote, store, httptest.NewRequest(http.MethodGet, "/x", nil), "repodata/repomd.xml").Code
|
||||
}()
|
||||
waitFor(t, func() bool { return sfWaiters() == 1 })
|
||||
cancelLeader()
|
||||
close(gate)
|
||||
for range 2 {
|
||||
if c := <-results; c != http.StatusOK {
|
||||
t.Fatalf("code %d after leader cancel, want 200", c)
|
||||
}
|
||||
}
|
||||
if got := store.listCount(); got != 1 {
|
||||
t.Fatalf("full metadata reads = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCachedRepodataOlderBuildDoesNotOverwriteNewer(t *testing.T) {
|
||||
p, store, remote := newCacheFixture(t, true)
|
||||
newer := &repodata{}
|
||||
p.repodataCache[remote.Name] = cachedRepodata{count: 99, maxID: 99, seq: 1 << 40, used: time.Now(), repodata: newer}
|
||||
serve(t, p, remote, store, "repodata/repomd.xml")
|
||||
if p.repodataCache[remote.Name].repodata != newer {
|
||||
t.Fatal("older build replaced a newer cache entry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCachedRepodataErrors(t *testing.T) {
|
||||
secret := errors.New("pq: password authentication failed for user secret")
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
fpErr, listErr error
|
||||
code int
|
||||
}{
|
||||
{"fingerprint", secret, nil, http.StatusInternalServerError},
|
||||
{"list", nil, secret, http.StatusInternalServerError},
|
||||
{"fingerprint deadline", context.DeadlineExceeded, nil, http.StatusServiceUnavailable},
|
||||
{"list canceled", nil, context.Canceled, http.StatusServiceUnavailable},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
p, store, remote := newCacheFixture(t, true)
|
||||
store.fpErr, store.listErr = tc.fpErr, tc.listErr
|
||||
rec := serveReq(t, p, remote, store, httptest.NewRequest(http.MethodGet, "/x", nil), "repodata/repomd.xml")
|
||||
if rec.Code != tc.code {
|
||||
t.Fatalf("code %d, want %d", rec.Code, tc.code)
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "secret") {
|
||||
t.Fatalf("DB error leaked: %q", rec.Body.String())
|
||||
}
|
||||
if retry := rec.Header().Get("Retry-After"); (tc.code == http.StatusServiceUnavailable) != (retry != "") {
|
||||
t.Fatalf("Retry-After = %q for %d", retry, tc.code)
|
||||
}
|
||||
if _, ok := p.repodataCache[remote.Name]; ok {
|
||||
t.Fatal("failed build was cached")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCachedRepodataUnknownTail404sWithoutDB(t *testing.T) {
|
||||
p, store, remote := newCacheFixture(t, true)
|
||||
rec := serveReq(t, p, remote, store, httptest.NewRequest(http.MethodGet, "/x", nil), "repodata/bogus.txt")
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("code %d, want 404", rec.Code)
|
||||
}
|
||||
if store.fpCount() != 0 || store.listCount() != 0 {
|
||||
t.Fatalf("unknown tail hit the DB: fps=%d lists=%d", store.fpCount(), store.listCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheEmptyUsesFingerprint(t *testing.T) {
|
||||
p := newGitHubProvider()
|
||||
store := &fpStore{fakeStore: newFakeStore()}
|
||||
if !p.cacheEmpty(context.Background(), store, "cached-rpm") {
|
||||
t.Fatal("empty store not reported empty")
|
||||
}
|
||||
addPkg(t, store, "alpha")
|
||||
if p.cacheEmpty(context.Background(), store, "cached-rpm") {
|
||||
t.Fatal("populated store reported empty")
|
||||
}
|
||||
store.fpErr = errors.New("db down")
|
||||
if p.cacheEmpty(context.Background(), store, "cached-rpm") {
|
||||
t.Fatal("fingerprint error must not report empty")
|
||||
}
|
||||
if store.listCount() != 0 {
|
||||
t.Fatalf("cacheEmpty did full reads: %d", store.listCount())
|
||||
}
|
||||
}
|
||||
@@ -250,36 +250,52 @@ func (p *Provider) GenerateLocalIndex(ctx context.Context, files provider.FileSt
|
||||
func readMetadataEntries(w http.ResponseWriter, r *http.Request, reader provider.RPMMetadataReader, repoName string) ([]provider.RPMMetadata, bool) {
|
||||
metas, err := reader.ListRPMMetadataEntries(r.Context(), repoName)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
slog.Warn("rpm: metadata read canceled", "repo", repoName, "error", err)
|
||||
http.Error(w, "metadata read canceled", http.StatusServiceUnavailable)
|
||||
return nil, false
|
||||
}
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
writeMetadataError(w, repoName, err)
|
||||
return nil, false
|
||||
}
|
||||
return metas, true
|
||||
}
|
||||
|
||||
// writeMetadataError maps a metadata read failure to a response without leaking
|
||||
// DB error text: a canceled/expired context is a retryable 503, anything else a 500.
|
||||
func writeMetadataError(w http.ResponseWriter, repoName string, err error) {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
slog.Warn("rpm: metadata read canceled", "repo", repoName, "error", err)
|
||||
w.Header().Set("Retry-After", "5")
|
||||
http.Error(w, "metadata read canceled", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
slog.Error("rpm: metadata read failed", "repo", repoName, "error", err)
|
||||
http.Error(w, "metadata read failed", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
func (p *Provider) serveRepomd(w http.ResponseWriter, r *http.Request, reader provider.RPMMetadataReader, repoName string) {
|
||||
metas, ok := readMetadataEntries(w, r, reader, repoName)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
primary := generatePrimaryXMLGZ(metas)
|
||||
filelists := generateFilelistsXMLGZ(metas)
|
||||
other := generateOtherXMLGZ(metas)
|
||||
|
||||
primaryHash := sha256Hex(primary)
|
||||
filelistsHash := sha256Hex(filelists)
|
||||
otherHash := sha256Hex(other)
|
||||
|
||||
repomd := generateRepomd(repomdRevision(metas), primaryHash, len(primary), filelistsHash, len(filelists), otherHash, len(other))
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(repomd)
|
||||
_, _ = w.Write(buildRepodata(metas).repomd)
|
||||
}
|
||||
|
||||
// repodata is a complete generated index: repomd.xml plus the three gz files it references.
|
||||
type repodata struct {
|
||||
repomd, primary, filelists, other []byte
|
||||
}
|
||||
|
||||
func buildRepodata(metas []provider.RPMMetadata) *repodata {
|
||||
rd := &repodata{
|
||||
primary: generatePrimaryXMLGZ(metas),
|
||||
filelists: generateFilelistsXMLGZ(metas),
|
||||
other: generateOtherXMLGZ(metas),
|
||||
}
|
||||
rd.repomd = generateRepomd(repomdRevision(metas),
|
||||
sha256Hex(rd.primary), len(rd.primary),
|
||||
sha256Hex(rd.filelists), len(rd.filelists),
|
||||
sha256Hex(rd.other), len(rd.other))
|
||||
return rd
|
||||
}
|
||||
|
||||
func (p *Provider) servePrimary(w http.ResponseWriter, r *http.Request, reader provider.RPMMetadataReader, repoName string) {
|
||||
|
||||
@@ -21,7 +21,7 @@ const (
|
||||
// scan never loses its lease, while a crashed replica's lease still expires.
|
||||
syncLeaseDuration = 15 * time.Minute
|
||||
// defaultSyncFreshness is the periodic re-check interval used when a remote's
|
||||
// mutable_ttl is unset.
|
||||
// sync_interval and mutable_ttl are unset.
|
||||
defaultSyncFreshness = 5 * time.Minute
|
||||
// jobQueueDepth bounds the pending work queue; enqueues past it are dropped
|
||||
// (a later poll re-enqueues), never blocking the caller.
|
||||
@@ -34,7 +34,7 @@ const (
|
||||
type SyncStore interface {
|
||||
provider.RemoteMetadataStore
|
||||
ListGitHubRPMRemotes(ctx context.Context) ([]models.Remote, error)
|
||||
ClaimGitHubSyncLease(ctx context.Context, remoteName, owner string, freshness, lease time.Duration) (claimed bool, etag string, err error)
|
||||
ClaimGitHubSyncLease(ctx context.Context, remoteName, owner string, freshness, lease, interval, phase time.Duration) (claimed bool, etag string, err error)
|
||||
ReleaseGitHubSyncLease(ctx context.Context, remoteName, owner string, res provider.SyncResult) error
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ type SyncConfig struct {
|
||||
RatePerSec float64 // global GitHub request rate (req/s)
|
||||
Burst int // token-bucket burst
|
||||
Workers int // concurrent scan workers
|
||||
PollInterval time.Duration // base scheduler tick; per-remote cadence is mutable_ttl
|
||||
PollInterval time.Duration // base scheduler tick; per-remote cadence is sync_interval or mutable_ttl
|
||||
}
|
||||
|
||||
type syncJob struct {
|
||||
@@ -139,9 +139,9 @@ func (s *Syncer) Run(ctx context.Context) {
|
||||
}
|
||||
|
||||
// schedule enqueues a periodic check for every github_rpm remote. The DB lease
|
||||
// (claimed in the worker) enforces the per-remote mutable_ttl cadence and cross
|
||||
// replica coordination, so enqueuing every tick is cheap: a not-yet-due remote
|
||||
// simply fails to claim and is skipped.
|
||||
// (claimed in the worker) enforces the per-remote sync_interval (else
|
||||
// mutable_ttl) cadence and cross replica coordination, so enqueuing every tick
|
||||
// is cheap: a not-yet-due remote simply fails to claim and is skipped.
|
||||
func (s *Syncer) schedule(ctx context.Context) {
|
||||
remotes, err := s.store.ListGitHubRPMRemotes(ctx)
|
||||
if err != nil {
|
||||
@@ -155,8 +155,10 @@ func (s *Syncer) schedule(ctx context.Context) {
|
||||
|
||||
// EnqueuePrime queues an immediate background prime for a freshly created
|
||||
// remote so its metadata is derived without blocking the create call.
|
||||
// A sync_interval remote skips it and syncs in its first slot, so remotes
|
||||
// created together do not burst.
|
||||
func (s *Syncer) EnqueuePrime(remote models.Remote) {
|
||||
if s == nil {
|
||||
if s == nil || remote.SyncInterval > 0 {
|
||||
return
|
||||
}
|
||||
s.enqueue(remote, true)
|
||||
@@ -205,16 +207,16 @@ func (s *Syncer) process(ctx context.Context, job syncJob) {
|
||||
s.mu.Unlock()
|
||||
}()
|
||||
|
||||
ttl := time.Duration(job.remote.MutableTTL) * time.Second
|
||||
if ttl <= 0 {
|
||||
ttl = defaultSyncFreshness
|
||||
}
|
||||
ttl := job.remote.SyncPeriod(defaultSyncFreshness)
|
||||
freshness := ttl
|
||||
var interval, phase time.Duration
|
||||
if job.prime {
|
||||
freshness = 0 // prime ignores the recency gate but still respects a live lease
|
||||
} else {
|
||||
interval, phase = job.remote.SyncSchedule()
|
||||
}
|
||||
|
||||
claimed, etag, err := s.store.ClaimGitHubSyncLease(ctx, job.remote.Name, s.owner, freshness, syncLeaseDuration)
|
||||
claimed, etag, err := s.store.ClaimGitHubSyncLease(ctx, job.remote.Name, s.owner, freshness, syncLeaseDuration, interval, phase)
|
||||
if err != nil {
|
||||
slog.Error("github_rpm syncer: claim lease", "remote", job.remote.Name, "error", err)
|
||||
return
|
||||
|
||||
@@ -29,6 +29,10 @@ type fakeSyncStore struct {
|
||||
etags map[string]string
|
||||
retryAt map[string]time.Time
|
||||
lastResult provider.SyncResult
|
||||
freshness time.Duration
|
||||
interval time.Duration
|
||||
phase time.Duration
|
||||
attempts int
|
||||
}
|
||||
|
||||
func newFakeSyncStore() *fakeSyncStore {
|
||||
@@ -48,9 +52,11 @@ func (f *fakeSyncStore) ListGitHubRPMRemotes(_ context.Context) ([]models.Remote
|
||||
return append([]models.Remote(nil), f.remotes...), nil
|
||||
}
|
||||
|
||||
func (f *fakeSyncStore) ClaimGitHubSyncLease(_ context.Context, name, owner string, freshness, lease time.Duration) (bool, string, error) {
|
||||
func (f *fakeSyncStore) ClaimGitHubSyncLease(_ context.Context, name, owner string, freshness, lease, interval, phase time.Duration) (bool, string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.freshness, f.interval, f.phase = freshness, interval, phase
|
||||
f.attempts++
|
||||
now := time.Now()
|
||||
ls, hasLS := f.lastSynced[name]
|
||||
exp, hasExp := f.leaseExp[name]
|
||||
@@ -230,7 +236,7 @@ func TestSyncerLeasePreventsSecondReplica(t *testing.T) {
|
||||
remote := fx.remote()
|
||||
|
||||
// Replica 1 holds the lease.
|
||||
claimed, _, err := store.ClaimGitHubSyncLease(context.Background(), remote.Name, "replica-1", time.Duration(remote.MutableTTL)*time.Second, syncLeaseDuration)
|
||||
claimed, _, err := store.ClaimGitHubSyncLease(context.Background(), remote.Name, "replica-1", time.Duration(remote.MutableTTL)*time.Second, syncLeaseDuration, 0, 0)
|
||||
if err != nil || !claimed {
|
||||
t.Fatalf("replica-1 claim: claimed=%v err=%v", claimed, err)
|
||||
}
|
||||
@@ -470,3 +476,43 @@ func TestSyncerFailedAssetRetry(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A periodic check claims with sync_interval as its freshness and passes the
|
||||
// remote's slot schedule; unset, it falls back to mutable_ttl with no schedule.
|
||||
func TestSyncerHonoursSyncInterval(t *testing.T) {
|
||||
for _, interval := range []int{0, 7200} {
|
||||
store := newFakeSyncStore()
|
||||
s := newSyncer(store, newTestProvider(), testSyncConfig())
|
||||
store.lastSynced["sched"] = time.Now()
|
||||
remote := models.Remote{Name: "sched", MutableTTL: 90, SyncInterval: interval}
|
||||
wantInterval, wantPhase := remote.SyncSchedule()
|
||||
s.process(context.Background(), syncJob{remote: remote})
|
||||
if want := remote.SyncPeriod(0); store.freshness != want || store.interval != wantInterval || store.phase != wantPhase {
|
||||
t.Errorf("sync_interval=%d: claim freshness/interval/phase = %v/%v/%v, want %v/%v/%v",
|
||||
interval, store.freshness, store.interval, store.phase, want, wantInterval, wantPhase)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create-time primes are skipped for a scheduled remote so remotes created
|
||||
// together spread out; a cold-cache prime still claims with no schedule.
|
||||
func TestSyncerScheduledPrime(t *testing.T) {
|
||||
store := newFakeSyncStore()
|
||||
s := newSyncer(store, newTestProvider(), testSyncConfig())
|
||||
remote := models.Remote{Name: "fresh", SyncInterval: 3600}
|
||||
|
||||
s.EnqueuePrime(remote)
|
||||
if len(s.jobs) != 0 {
|
||||
t.Fatal("create-time prime enqueued for a sync_interval remote")
|
||||
}
|
||||
s.process(context.Background(), syncJob{remote: remote, prime: true})
|
||||
if store.attempts != 1 || store.freshness != 0 || store.interval != 0 {
|
||||
t.Fatalf("cold-cache prime: attempts=%d freshness=%v interval=%v, want 1/0/0", store.attempts, store.freshness, store.interval)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncLeaseOutlivesScanTimeout(t *testing.T) {
|
||||
if syncLeaseDuration <= defaultScanTimeout {
|
||||
t.Fatalf("syncLeaseDuration %v must exceed defaultScanTimeout %v so scans stay serialised", syncLeaseDuration, defaultScanTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
// Package vcs caches source archives and release assets from GitHub or Gitea,
|
||||
// keyed by ref: tags, commits and release assets are immutable, and branch
|
||||
// archives redirect to the archive of the commit the branch currently points at.
|
||||
package vcs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sync/singleflight"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/provider"
|
||||
"git.unkin.net/unkin/artifactapi/internal/provider/generic"
|
||||
"git.unkin.net/unkin/artifactapi/internal/proxy"
|
||||
"git.unkin.net/unkin/artifactapi/pkg/models"
|
||||
)
|
||||
|
||||
func init() {
|
||||
provider.Register(New())
|
||||
}
|
||||
|
||||
type kind int
|
||||
|
||||
const (
|
||||
tagArchive kind = iota + 1
|
||||
branchArchive
|
||||
commitArchive
|
||||
releaseAsset
|
||||
latestReleaseAsset
|
||||
)
|
||||
|
||||
// target is a parsed vcs request path.
|
||||
type target struct {
|
||||
Kind kind
|
||||
Owner, Repo string
|
||||
Name string // tag, branch, commit or asset
|
||||
Ext string
|
||||
}
|
||||
|
||||
var (
|
||||
segmentRe = regexp.MustCompile(`^[A-Za-z0-9._+-]+$`)
|
||||
commitRe = regexp.MustCompile(`^[0-9a-f]{40}$`)
|
||||
)
|
||||
|
||||
var errNotFound = errors.New("ref not found")
|
||||
|
||||
const (
|
||||
maxRefs = 4096
|
||||
negativeTTL = time.Minute
|
||||
)
|
||||
|
||||
// Provider embeds generic for upstream URLs, content types and GitHub/Basic auth.
|
||||
type Provider struct {
|
||||
generic.Provider
|
||||
|
||||
client *http.Client
|
||||
githubAPI string
|
||||
|
||||
group singleflight.Group
|
||||
mu sync.Mutex
|
||||
refs map[string]map[string]resolved
|
||||
}
|
||||
|
||||
// resolved is a branch lookup; an empty commit records a missing branch.
|
||||
type resolved struct {
|
||||
commit string
|
||||
at time.Time
|
||||
}
|
||||
|
||||
func New() *Provider {
|
||||
return &Provider{
|
||||
client: &http.Client{Timeout: 30 * time.Second},
|
||||
githubAPI: "https://api.github.com",
|
||||
refs: map[string]map[string]resolved{},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Provider) Type() models.PackageType { return models.PackageVCS }
|
||||
|
||||
func (p *Provider) Classify(path string) provider.Mutability {
|
||||
if ref, ok := parsePath(path); ok && (ref.Kind == branchArchive || ref.Kind == latestReleaseAsset) {
|
||||
return provider.Mutable
|
||||
}
|
||||
return provider.Immutable
|
||||
}
|
||||
|
||||
// parsePath accepts only the archive and release-download shapes, rejecting
|
||||
// any segment that could escape owner/repo or a ref.
|
||||
func parsePath(path string) (target, bool) {
|
||||
parts := strings.SplitN(strings.TrimPrefix(path, "/"), "/", 3)
|
||||
if len(parts) != 3 || !validSegment(parts[0]) || !validSegment(parts[1]) {
|
||||
return target{}, false
|
||||
}
|
||||
ref := target{Owner: parts[0], Repo: parts[1]}
|
||||
rest := parts[2]
|
||||
switch {
|
||||
case strings.HasPrefix(rest, "archive/"):
|
||||
name, ext, ok := archiveName(strings.TrimPrefix(rest, "archive/"))
|
||||
if !ok {
|
||||
return target{}, false
|
||||
}
|
||||
ref.Ext = ext
|
||||
switch {
|
||||
case strings.HasPrefix(name, "refs/tags/"):
|
||||
ref.Kind, ref.Name = tagArchive, strings.TrimPrefix(name, "refs/tags/")
|
||||
case strings.HasPrefix(name, "refs/heads/"):
|
||||
ref.Kind, ref.Name = branchArchive, strings.TrimPrefix(name, "refs/heads/")
|
||||
case commitRe.MatchString(name):
|
||||
return target{Kind: commitArchive, Owner: ref.Owner, Repo: ref.Repo, Name: name, Ext: ext}, true
|
||||
default:
|
||||
return target{}, false
|
||||
}
|
||||
return ref, validRefName(ref.Name)
|
||||
case strings.HasPrefix(rest, "releases/latest/download/"):
|
||||
ref.Kind, ref.Name = latestReleaseAsset, strings.TrimPrefix(rest, "releases/latest/download/")
|
||||
return ref, validSegment(ref.Name)
|
||||
case strings.HasPrefix(rest, "releases/download/"):
|
||||
tagAsset := strings.TrimPrefix(rest, "releases/download/")
|
||||
i := strings.LastIndex(tagAsset, "/")
|
||||
if i < 0 {
|
||||
return target{}, false
|
||||
}
|
||||
ref.Kind, ref.Name = releaseAsset, tagAsset[i+1:]
|
||||
return ref, validRefName(tagAsset[:i]) && validSegment(ref.Name)
|
||||
}
|
||||
return target{}, false
|
||||
}
|
||||
|
||||
func archiveName(s string) (string, string, bool) {
|
||||
for _, ext := range []string{".tar.gz", ".zip"} {
|
||||
if name, ok := strings.CutSuffix(s, ext); ok && name != "" {
|
||||
return name, ext, true
|
||||
}
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
func validSegment(s string) bool {
|
||||
return segmentRe.MatchString(s) && s != "." && s != ".." && !strings.Contains(s, "..")
|
||||
}
|
||||
|
||||
func validRefName(s string) bool {
|
||||
for _, seg := range strings.Split(s, "/") {
|
||||
if !validSegment(seg) || strings.HasPrefix(seg, ".") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ServeRemote rejects unsupported paths and redirects branch archives to the
|
||||
// immutable archive of the branch's current commit; everything else is proxied.
|
||||
func (p *Provider) ServeRemote(w http.ResponseWriter, r *http.Request, remote models.Remote, path, proxyBaseURL string, _ provider.RemoteMetadataStore) bool {
|
||||
ref, ok := parsePath(path)
|
||||
if !ok {
|
||||
http.Error(w, "unsupported vcs path", http.StatusNotFound)
|
||||
return true
|
||||
}
|
||||
if ref.Kind != branchArchive {
|
||||
return false
|
||||
}
|
||||
classifier := proxy.NewClassifier(p)
|
||||
if classifier.Classify(remote, path) == proxy.ClassDenied {
|
||||
http.Error(w, "access denied", http.StatusForbidden)
|
||||
return true
|
||||
}
|
||||
commit, err := p.resolve(r.Context(), remote, ref)
|
||||
var se *provider.UpstreamStatusError
|
||||
switch {
|
||||
case errors.Is(err, errNotFound):
|
||||
http.Error(w, "branch not found", http.StatusNotFound)
|
||||
return true
|
||||
case errors.As(err, &se) && (se.Status == http.StatusTooManyRequests || se.Status == http.StatusForbidden && !se.RetryAt.IsZero()):
|
||||
secs := 60
|
||||
if !se.RetryAt.IsZero() {
|
||||
secs = max(1, int(time.Until(se.RetryAt).Seconds()+1))
|
||||
}
|
||||
w.Header().Set("Retry-After", strconv.Itoa(secs))
|
||||
http.Error(w, "upstream rate limited", http.StatusServiceUnavailable)
|
||||
return true
|
||||
case err != nil:
|
||||
http.Error(w, "bad gateway", http.StatusBadGateway)
|
||||
return true
|
||||
}
|
||||
target := ref.Owner + "/" + ref.Repo + "/archive/" + commit + ref.Ext
|
||||
if classifier.Classify(remote, target) == proxy.ClassDenied {
|
||||
http.Error(w, "access denied", http.StatusForbidden)
|
||||
return true
|
||||
}
|
||||
http.Redirect(w, r, strings.TrimRight(proxyBaseURL, "/")+provider.RemoteMountPath+remote.Name+"/"+target, http.StatusFound)
|
||||
return true
|
||||
}
|
||||
|
||||
// resolve maps a branch to its commit, reusing the answer for mutable_ttl (a
|
||||
// missing branch for at least negativeTTL) and, with stale_on_error, past an
|
||||
// upstream failure. Concurrent lookups of one branch share a single API call.
|
||||
func (p *Provider) resolve(ctx context.Context, remote models.Remote, ref target) (string, error) {
|
||||
key := ref.Owner + "/" + ref.Repo + "\x00" + ref.Name
|
||||
ttl := time.Duration(remote.MutableTTL) * time.Second
|
||||
p.mu.Lock()
|
||||
prev, ok := p.refs[remote.Name][key]
|
||||
p.mu.Unlock()
|
||||
if ok && prev.commit != "" && time.Since(prev.at) < ttl {
|
||||
return prev.commit, nil
|
||||
}
|
||||
if ok && prev.commit == "" && time.Since(prev.at) < max(ttl, negativeTTL) {
|
||||
return "", errNotFound
|
||||
}
|
||||
v, err, _ := p.group.Do(remote.Name+"\x00"+key, func() (any, error) {
|
||||
commit, err := p.lookup(context.WithoutCancel(ctx), remote, ref)
|
||||
if err == nil || errors.Is(err, errNotFound) {
|
||||
p.store(remote.Name, key, commit)
|
||||
}
|
||||
return commit, err
|
||||
})
|
||||
if err != nil {
|
||||
if ok && prev.commit != "" && remote.StaleOnError && !errors.Is(err, errNotFound) {
|
||||
return prev.commit, nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return v.(string), nil
|
||||
}
|
||||
|
||||
// store records a lookup, evicting the remote's oldest entry once it holds maxRefs.
|
||||
func (p *Provider) store(remote, key, commit string) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
m := p.refs[remote]
|
||||
if m == nil {
|
||||
m = map[string]resolved{}
|
||||
p.refs[remote] = m
|
||||
}
|
||||
if _, ok := m[key]; !ok && len(m) >= maxRefs {
|
||||
var oldest string
|
||||
for k, e := range m {
|
||||
if oldest == "" || e.at.Before(m[oldest].at) {
|
||||
oldest = k
|
||||
}
|
||||
}
|
||||
delete(m, oldest)
|
||||
}
|
||||
m[key] = resolved{commit: commit, at: time.Now()}
|
||||
}
|
||||
|
||||
func (p *Provider) lookup(ctx context.Context, remote models.Remote, ref target) (string, error) {
|
||||
segs := strings.Split(ref.Name, "/")
|
||||
for i, s := range segs {
|
||||
segs[i] = url.PathEscape(s)
|
||||
}
|
||||
branch := strings.Join(segs, "/")
|
||||
github := generic.IsGitHubHost(remote.BaseURL)
|
||||
api := strings.TrimRight(remote.BaseURL, "/") + "/api/v1/repos/" + ref.Owner + "/" + ref.Repo + "/branches/" + branch
|
||||
if github {
|
||||
api = p.githubAPI + "/repos/" + ref.Owner + "/" + ref.Repo + "/git/ref/heads/" + branch
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, api, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
h, err := p.AuthHeaders(ctx, remote)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header = h
|
||||
if github {
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
}
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusUnprocessableEntity {
|
||||
return "", errNotFound
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", provider.NewUpstreamStatusError(api, resp)
|
||||
}
|
||||
var b struct {
|
||||
Commit struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"commit"`
|
||||
Object struct {
|
||||
SHA string `json:"sha"`
|
||||
} `json:"object"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
commit := b.Commit.ID
|
||||
if github {
|
||||
commit = b.Object.SHA
|
||||
}
|
||||
if !commitRe.MatchString(commit) {
|
||||
return "", fmt.Errorf("branch lookup %s: unexpected commit %q", api, commit)
|
||||
}
|
||||
return commit, nil
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
package vcs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/githubauth"
|
||||
"git.unkin.net/unkin/artifactapi/internal/provider"
|
||||
"git.unkin.net/unkin/artifactapi/pkg/models"
|
||||
)
|
||||
|
||||
const (
|
||||
shaA = "4f2cfe92e3499f8581bf39db77e0a1431fa7b55c"
|
||||
shaB = "7fa5434dbc083c853eef435d1915020345219d2f"
|
||||
)
|
||||
|
||||
func TestParsePath(t *testing.T) {
|
||||
tests := []struct {
|
||||
path string
|
||||
ok bool
|
||||
kind kind
|
||||
name string
|
||||
}{
|
||||
{"cli/cli/archive/refs/tags/v2.60.0.tar.gz", true, tagArchive, "v2.60.0"},
|
||||
{"/cli/cli/archive/refs/tags/v2.60.0.zip", true, tagArchive, "v2.60.0"},
|
||||
{"o/r/archive/refs/tags/release/1.0.tar.gz", true, tagArchive, "release/1.0"},
|
||||
{"o/r/archive/refs/heads/feature/x.tar.gz", true, branchArchive, "feature/x"},
|
||||
{"o/r/archive/" + shaA + ".tar.gz", true, commitArchive, shaA},
|
||||
{"o/r/releases/download/v1.0/tool_linux_amd64.tar.gz", true, releaseAsset, "tool_linux_amd64.tar.gz"},
|
||||
{"o/r/releases/latest/download/tool.zip", true, latestReleaseAsset, "tool.zip"},
|
||||
{"o/r/archive/main.tar.gz", false, 0, ""},
|
||||
{"o/r/archive/" + shaA[:7] + ".tar.gz", false, 0, ""},
|
||||
{"o/r/archive/refs/tags/v1.tar.bz2", false, 0, ""},
|
||||
{"o/r/archive/refs/tags/.tar.gz", false, 0, ""},
|
||||
{"../r/archive/refs/tags/v1.tar.gz", false, 0, ""},
|
||||
{"o/../archive/refs/tags/v1.tar.gz", false, 0, ""},
|
||||
{"o/r/archive/refs/tags/../../../etc/passwd.tar.gz", false, 0, ""},
|
||||
{"o/r/archive/refs/heads/a//b.tar.gz", false, 0, ""},
|
||||
{"o/r/archive/refs/heads/.hidden.tar.gz", false, 0, ""},
|
||||
{"o/r/archive/refs/heads/a..b.tar.gz", false, 0, ""},
|
||||
{"o/r/archive/refs/tags/v1%2f.tar.gz", false, 0, ""},
|
||||
{"o/r/releases/download/v1/../x", false, 0, ""},
|
||||
{"o/r/releases/download/asset", false, 0, ""},
|
||||
{"o/r/raw/main/README.md", false, 0, ""},
|
||||
{"o/r", false, 0, ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
ref, ok := parsePath(tt.path)
|
||||
if ok != tt.ok || (ok && (ref.Kind != tt.kind || ref.Name != tt.name)) {
|
||||
t.Errorf("parsePath(%q) = %+v, %v; want kind %v name %q ok %v", tt.path, ref, ok, tt.kind, tt.name, tt.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassify(t *testing.T) {
|
||||
p := New()
|
||||
for path, want := range map[string]provider.Mutability{
|
||||
"o/r/archive/refs/tags/v1.tar.gz": provider.Immutable,
|
||||
"o/r/archive/" + shaA + ".zip": provider.Immutable,
|
||||
"o/r/releases/download/v1/a.bin": provider.Immutable,
|
||||
"o/r/archive/refs/heads/main.tar.gz": provider.Mutable,
|
||||
"o/r/releases/latest/download/webui.zip": provider.Mutable,
|
||||
} {
|
||||
if got := p.Classify(path); got != want {
|
||||
t.Errorf("Classify(%q) = %v, want %v", path, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type upstream struct {
|
||||
srv *httptest.Server
|
||||
hits atomic.Int32
|
||||
commit atomic.Value
|
||||
status atomic.Int32
|
||||
header atomic.Value
|
||||
accept atomic.Value
|
||||
auth atomic.Value
|
||||
path atomic.Value
|
||||
gate chan struct{}
|
||||
}
|
||||
|
||||
func newUpstream(t *testing.T, gitea bool) *upstream {
|
||||
u := &upstream{}
|
||||
u.commit.Store(shaA)
|
||||
u.status.Store(http.StatusOK)
|
||||
u.header.Store(http.Header{})
|
||||
u.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
u.hits.Add(1)
|
||||
if u.gate != nil {
|
||||
<-u.gate
|
||||
}
|
||||
u.accept.Store(r.Header.Get("Accept"))
|
||||
u.auth.Store(r.Header.Get("Authorization"))
|
||||
u.path.Store(r.URL.EscapedPath())
|
||||
for k, v := range u.header.Load().(http.Header) {
|
||||
w.Header()[k] = v
|
||||
}
|
||||
if s := int(u.status.Load()); s != http.StatusOK {
|
||||
w.WriteHeader(s)
|
||||
return
|
||||
}
|
||||
if gitea {
|
||||
_, _ = w.Write([]byte(`{"name":"main","commit":{"id":"` + u.commit.Load().(string) + `"}}`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"ref":"refs/heads/main","object":{"sha":"` + u.commit.Load().(string) + `","type":"commit"}}`))
|
||||
}))
|
||||
t.Cleanup(u.srv.Close)
|
||||
return u
|
||||
}
|
||||
|
||||
func serve(p *Provider, remote models.Remote, path string) (*httptest.ResponseRecorder, bool) {
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/api/v1/remote/"+remote.Name+"/"+path, nil)
|
||||
return w, p.ServeRemote(w, r, remote, path, "http://proxy", nil)
|
||||
}
|
||||
|
||||
// age backdates every cached lookup of a remote by d.
|
||||
func age(p *Provider, remote string, d time.Duration) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
for k, e := range p.refs[remote] {
|
||||
e.at = e.at.Add(-d)
|
||||
p.refs[remote][k] = e
|
||||
}
|
||||
}
|
||||
|
||||
type staticCred string
|
||||
|
||||
func (s staticCred) Token(context.Context) (string, error) { return string(s), nil }
|
||||
|
||||
func TestServeRemoteGiteaBranchRedirectsToCommit(t *testing.T) {
|
||||
u := newUpstream(t, true)
|
||||
p := New()
|
||||
remote := models.Remote{Name: "gitea", BaseURL: u.srv.URL, MutableTTL: 3600, Username: "bot", Password: "pw"}
|
||||
|
||||
w, handled := serve(p, remote, "unkin/app/archive/refs/heads/feature/x.zip")
|
||||
if !handled || w.Code != http.StatusFound {
|
||||
t.Fatalf("handled=%v code=%d", handled, w.Code)
|
||||
}
|
||||
if got, want := w.Header().Get("Location"), "http://proxy/api/v1/remote/gitea/unkin/app/archive/"+shaA+".zip"; got != want {
|
||||
t.Errorf("Location = %q, want %q", got, want)
|
||||
}
|
||||
if got := u.path.Load(); got != "/api/v1/repos/unkin/app/branches/feature/x" {
|
||||
t.Errorf("api path = %q", got)
|
||||
}
|
||||
if got := u.auth.Load(); got != "Basic Ym90OnB3" {
|
||||
t.Errorf("Authorization = %q, want remote basic auth", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeRemoteGitHubBranchUsesAPI(t *testing.T) {
|
||||
githubauth.SetServer(staticCred("ghs_server"))
|
||||
t.Cleanup(func() { githubauth.SetServer(nil) })
|
||||
u := newUpstream(t, false)
|
||||
p := New()
|
||||
p.githubAPI = u.srv.URL
|
||||
|
||||
for _, base := range []string{"https://github.com", "https://www.github.com/", "https://api.github.com"} {
|
||||
remote := models.Remote{Name: base, BaseURL: base, MutableTTL: 3600}
|
||||
w, _ := serve(p, remote, "cli/cli/archive/refs/heads/feature/x.tar.gz")
|
||||
if !strings.HasSuffix(w.Header().Get("Location"), "/cli/cli/archive/"+shaA+".tar.gz") {
|
||||
t.Fatalf("%s: Location = %q", base, w.Header().Get("Location"))
|
||||
}
|
||||
if u.path.Load() != "/repos/cli/cli/git/ref/heads/feature/x" || u.accept.Load() != "application/vnd.github+json" {
|
||||
t.Errorf("%s: api path %q accept %q", base, u.path.Load(), u.accept.Load())
|
||||
}
|
||||
if got := u.auth.Load(); got != "Bearer ghs_server" {
|
||||
t.Errorf("%s: Authorization = %q, want server credential", base, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeRemoteBranchResolutionHonoursMutableTTL(t *testing.T) {
|
||||
u := newUpstream(t, true)
|
||||
p := New()
|
||||
remote := models.Remote{Name: "g", BaseURL: u.srv.URL, MutableTTL: 600}
|
||||
path := "o/r/archive/refs/heads/main.tar.gz"
|
||||
|
||||
serve(p, remote, path)
|
||||
u.commit.Store(shaB)
|
||||
age(p, remote.Name, 599*time.Second)
|
||||
w, _ := serve(p, remote, path)
|
||||
if u.hits.Load() != 1 || !strings.Contains(w.Header().Get("Location"), shaA) {
|
||||
t.Fatalf("within ttl: hits=%d location=%q", u.hits.Load(), w.Header().Get("Location"))
|
||||
}
|
||||
|
||||
age(p, remote.Name, 2*time.Second)
|
||||
w, _ = serve(p, remote, path)
|
||||
if u.hits.Load() != 2 || !strings.Contains(w.Header().Get("Location"), shaB) {
|
||||
t.Fatalf("past ttl: hits=%d location=%q", u.hits.Load(), w.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeRemoteBranchUpstreamFailure(t *testing.T) {
|
||||
path := "o/r/archive/refs/heads/main.tar.gz"
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
stale bool
|
||||
want int
|
||||
}{
|
||||
{"stale", true, http.StatusFound},
|
||||
{"strict", false, http.StatusBadGateway},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
u := newUpstream(t, true)
|
||||
p := New()
|
||||
remote := models.Remote{Name: tc.name, BaseURL: u.srv.URL, StaleOnError: tc.stale}
|
||||
serve(p, remote, path)
|
||||
u.status.Store(http.StatusInternalServerError)
|
||||
if w, _ := serve(p, remote, path); w.Code != tc.want {
|
||||
t.Fatalf("code = %d, want %d", w.Code, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeRemoteBranchRateLimited(t *testing.T) {
|
||||
reset := strconv.FormatInt(time.Now().Add(120*time.Second).Unix(), 10)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
status int
|
||||
header http.Header
|
||||
want int
|
||||
retry [2]int
|
||||
}{
|
||||
{"primary 403", http.StatusForbidden, http.Header{"X-Ratelimit-Remaining": {"0"}, "X-Ratelimit-Reset": {reset}}, http.StatusServiceUnavailable, [2]int{100, 122}},
|
||||
{"secondary 403", http.StatusForbidden, http.Header{"Retry-After": {"30"}}, http.StatusServiceUnavailable, [2]int{30, 31}},
|
||||
{"429", http.StatusTooManyRequests, http.Header{"Retry-After": {"5"}}, http.StatusServiceUnavailable, [2]int{5, 6}},
|
||||
{"429 no hint", http.StatusTooManyRequests, http.Header{}, http.StatusServiceUnavailable, [2]int{60, 60}},
|
||||
{"plain 403", http.StatusForbidden, http.Header{}, http.StatusBadGateway, [2]int{}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
u := newUpstream(t, false)
|
||||
u.status.Store(int32(tc.status))
|
||||
u.header.Store(tc.header)
|
||||
p := New()
|
||||
p.githubAPI = u.srv.URL
|
||||
w, _ := serve(p, models.Remote{Name: "gh", BaseURL: "https://github.com"}, "o/r/archive/refs/heads/main.tar.gz")
|
||||
if w.Code != tc.want {
|
||||
t.Fatalf("code = %d, want %d", w.Code, tc.want)
|
||||
}
|
||||
ra := w.Header().Get("Retry-After")
|
||||
if tc.want != http.StatusServiceUnavailable {
|
||||
if ra != "" {
|
||||
t.Fatalf("Retry-After = %q on %d", ra, w.Code)
|
||||
}
|
||||
return
|
||||
}
|
||||
if n, err := strconv.Atoi(ra); err != nil || n < tc.retry[0] || n > tc.retry[1] {
|
||||
t.Fatalf("Retry-After = %q, want %d..%d", ra, tc.retry[0], tc.retry[1])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeRemoteBranchNotFoundIsCached(t *testing.T) {
|
||||
u := newUpstream(t, false)
|
||||
u.status.Store(http.StatusNotFound)
|
||||
p := New()
|
||||
p.githubAPI = u.srv.URL
|
||||
remote := models.Remote{Name: "gh", BaseURL: "https://github.com"}
|
||||
path := "o/r/archive/refs/heads/nope.tar.gz"
|
||||
|
||||
for range 3 {
|
||||
if w, _ := serve(p, remote, path); w.Code != http.StatusNotFound {
|
||||
t.Fatalf("code = %d, want 404", w.Code)
|
||||
}
|
||||
}
|
||||
if u.hits.Load() != 1 {
|
||||
t.Fatalf("hits = %d, want 1 within negative ttl", u.hits.Load())
|
||||
}
|
||||
u.status.Store(http.StatusOK)
|
||||
age(p, remote.Name, negativeTTL)
|
||||
if w, _ := serve(p, remote, path); w.Code != http.StatusFound || u.hits.Load() != 2 {
|
||||
t.Fatalf("after negative ttl: code=%d hits=%d", w.Code, u.hits.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeRemoteConcurrentLookupsShareOneCall(t *testing.T) {
|
||||
u := newUpstream(t, true)
|
||||
u.gate = make(chan struct{})
|
||||
p := New()
|
||||
remote := models.Remote{Name: "g", BaseURL: u.srv.URL, MutableTTL: 600}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
codes := make([]int, 20)
|
||||
for i := range codes {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
w, _ := serve(p, remote, "o/r/archive/refs/heads/main.tar.gz")
|
||||
codes[i] = w.Code
|
||||
}()
|
||||
}
|
||||
for u.hits.Load() == 0 {
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
close(u.gate)
|
||||
wg.Wait()
|
||||
if u.hits.Load() != 1 {
|
||||
t.Fatalf("hits = %d, want 1", u.hits.Load())
|
||||
}
|
||||
for i, c := range codes {
|
||||
if c != http.StatusFound {
|
||||
t.Fatalf("request %d: code %d", i, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreBoundsEntriesPerRemote(t *testing.T) {
|
||||
p := New()
|
||||
for i := range maxRefs + 10 {
|
||||
p.store("a", fmt.Sprint(i), "")
|
||||
}
|
||||
p.store("b", "x", shaA)
|
||||
if n := len(p.refs["a"]); n != maxRefs {
|
||||
t.Fatalf("remote a holds %d entries, want %d", n, maxRefs)
|
||||
}
|
||||
if len(p.refs["b"]) != 1 {
|
||||
t.Fatalf("remote b must keep its own budget")
|
||||
}
|
||||
if _, ok := p.refs["a"][fmt.Sprint(maxRefs+9)]; !ok {
|
||||
t.Fatalf("newest entry evicted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeRemoteRejectsBadCommit(t *testing.T) {
|
||||
u := newUpstream(t, true)
|
||||
u.commit.Store("../../etc")
|
||||
w, _ := serve(New(), models.Remote{Name: "g", BaseURL: u.srv.URL}, "o/r/archive/refs/heads/main.tar.gz")
|
||||
if w.Code != http.StatusBadGateway {
|
||||
t.Fatalf("code = %d, want 502", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeRemoteAllowlist(t *testing.T) {
|
||||
u := newUpstream(t, true)
|
||||
remote := models.Remote{Name: "g", BaseURL: u.srv.URL, Patterns: []string{"^allowed/repo/"}}
|
||||
if w, _ := serve(New(), remote, "other/repo/archive/refs/heads/main.tar.gz"); w.Code != http.StatusForbidden {
|
||||
t.Fatalf("code = %d, want 403", w.Code)
|
||||
}
|
||||
if u.hits.Load() != 0 {
|
||||
t.Fatalf("denied branch must not hit the API")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeRemoteBlockedRedirectTarget(t *testing.T) {
|
||||
u := newUpstream(t, true)
|
||||
remote := models.Remote{Name: "g", BaseURL: u.srv.URL, Blocklist: []string{"/archive/" + shaA}}
|
||||
w, _ := serve(New(), remote, "o/r/archive/refs/heads/main.tar.gz")
|
||||
if w.Code != http.StatusForbidden || w.Header().Get("Location") != "" {
|
||||
t.Fatalf("code = %d location %q, want 403 without redirect", w.Code, w.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeRemotePassesThroughAndRejects(t *testing.T) {
|
||||
p := New()
|
||||
remote := models.Remote{Name: "g", BaseURL: "https://github.com"}
|
||||
if _, handled := serve(p, remote, "o/r/archive/refs/tags/v1.tar.gz"); handled {
|
||||
t.Error("tag archives must go through the proxy engine")
|
||||
}
|
||||
if _, handled := serve(p, remote, "o/r/releases/download/v1/a.bin"); handled {
|
||||
t.Error("release assets must go through the proxy engine")
|
||||
}
|
||||
if w, handled := serve(p, remote, "o/r/raw/main/x"); !handled || w.Code != http.StatusNotFound {
|
||||
t.Errorf("unsupported path: handled=%v code=%d", handled, w.Code)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -217,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, "/") {
|
||||
@@ -240,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)
|
||||
}
|
||||
@@ -312,12 +325,12 @@ func (e *Engine) Head(ctx context.Context, remote models.Remote, path string, pr
|
||||
return &HeadResult{ContentType: info.ContentType, Size: info.Size, Source: "cache"}, nil
|
||||
}
|
||||
|
||||
return e.headUpstream(ctx, remote, path, prov)
|
||||
return e.headUpstream(ctx, remote, path, prov, class)
|
||||
}
|
||||
|
||||
// headUpstream issues an upstream HEAD, load-balancing across the remote's base
|
||||
// URLs and failing over to the next mirror on a network error or 5xx.
|
||||
func (e *Engine) headUpstream(ctx context.Context, remote models.Remote, path string, prov provider.Provider) (*HeadResult, error) {
|
||||
func (e *Engine) headUpstream(ctx context.Context, remote models.Remote, path string, prov provider.Provider, class Classification) (*HeadResult, error) {
|
||||
order := e.baseURLAttemptOrder(remote)
|
||||
if len(order) == 0 {
|
||||
return nil, &ProxyError{Status: http.StatusBadGateway, Message: "no upstream base_url configured"}
|
||||
@@ -325,7 +338,7 @@ func (e *Engine) headUpstream(ctx context.Context, remote models.Remote, path st
|
||||
var lastErr error
|
||||
for i, url := range order {
|
||||
ctr := e.beginAttempt(remote, url)
|
||||
result, err := e.headUpstreamOnce(ctx, withBaseURL(remote, url), path, prov)
|
||||
result, err := e.headUpstreamOnce(ctx, withBaseURL(remote, url), path, prov, class)
|
||||
endAttempt(ctr)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
@@ -340,7 +353,7 @@ func (e *Engine) headUpstream(ctx context.Context, remote models.Remote, path st
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func (e *Engine) headUpstreamOnce(ctx context.Context, remote models.Remote, path string, prov provider.Provider) (*HeadResult, error) {
|
||||
func (e *Engine) headUpstreamOnce(ctx context.Context, remote models.Remote, path string, prov provider.Provider, class Classification) (*HeadResult, error) {
|
||||
url := prov.UpstreamURL(remote, path)
|
||||
|
||||
authHeaders, err := prov.AuthHeaders(ctx, remote)
|
||||
@@ -387,6 +400,11 @@ func (e *Engine) headUpstreamOnce(ctx context.Context, remote models.Remote, pat
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, &ProxyError{Status: resp.StatusCode, Message: fmt.Sprintf("upstream returned %d", resp.StatusCode)}
|
||||
}
|
||||
if class == ClassImmutable {
|
||||
if err := e.checkQuarantine(ctx, remote, path, resp.Header); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
contentType := prov.ContentType(path)
|
||||
if ct := resp.Header.Get("Content-Type"); ct != "" {
|
||||
@@ -514,6 +532,11 @@ func (e *Engine) fetchFromUpstreamOnce(ctx context.Context, remote models.Remote
|
||||
}, nil
|
||||
}
|
||||
|
||||
if err := e.checkQuarantine(ctx, remote, path, resp.Header); err != nil {
|
||||
_ = resp.Body.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Immutable blobs are streamed through the content-addressable store
|
||||
// (tempfile -> sha256 -> S3) so arbitrarily large artifacts never sit
|
||||
// fully in memory. Immutable content is never rewritten in the proxy path.
|
||||
@@ -650,6 +673,43 @@ func (e *Engine) checkUpstreamOnce(ctx context.Context, remote models.Remote, pa
|
||||
return resp.StatusCode == http.StatusNotModified, nil
|
||||
}
|
||||
|
||||
// checkQuarantine refuses an uncached immutable artifact published less than
|
||||
// quarantine_days ago. The publish time is the upstream Last-Modified, capped
|
||||
// at now, else the first time this path was seen.
|
||||
func (e *Engine) checkQuarantine(ctx context.Context, remote models.Remote, path string, upstream http.Header) error {
|
||||
if !remote.QuarantineEnabled {
|
||||
return nil
|
||||
}
|
||||
if a, err := e.db.GetArtifact(ctx, remote.Name, path); err == nil && a != nil {
|
||||
return nil
|
||||
}
|
||||
days := remote.QuarantineDays
|
||||
if days < 1 {
|
||||
days = models.DefaultQuarantineDays
|
||||
}
|
||||
window := time.Duration(days) * 24 * time.Hour
|
||||
now := time.Now()
|
||||
published, err := http.ParseTime(upstream.Get("Last-Modified"))
|
||||
if err != nil {
|
||||
if published, err = e.cache.FirstSeen(ctx, remote.Name, path, window+24*time.Hour); err != nil {
|
||||
slog.Warn("first-seen lookup failed", "remote", remote.Name, "path", path, "error", err)
|
||||
return &ProxyError{Status: http.StatusServiceUnavailable, Message: "quarantine check unavailable", RetryAfter: 30}
|
||||
}
|
||||
}
|
||||
if published.After(now) {
|
||||
published = now
|
||||
}
|
||||
if until := published.Add(window); now.Before(until) {
|
||||
return &ProxyError{
|
||||
Status: http.StatusForbidden,
|
||||
Message: fmt.Sprintf("quarantined: published %s, available from %s", published.UTC().Format(time.RFC3339), until.UTC().Format(time.RFC3339)),
|
||||
Published: published.UTC(),
|
||||
Available: until.UTC(),
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// etagTTL keeps a mutable path's ETag well past its freshness key so an expired
|
||||
// path revalidates with a conditional HEAD, while idle paths still age out.
|
||||
func etagTTL(ttl time.Duration) time.Duration {
|
||||
@@ -808,10 +868,21 @@ func fetchBearerToken(ctx context.Context, wwwAuth string, remote models.Remote)
|
||||
type ProxyError struct {
|
||||
Status int
|
||||
Message string
|
||||
// Published and Available are set only on a quarantine refusal.
|
||||
Published time.Time
|
||||
Available time.Time
|
||||
// RetryAfter, in seconds, is sent as Retry-After when set.
|
||||
RetryAfter int
|
||||
}
|
||||
|
||||
func (e *ProxyError) Error() string { return e.Message }
|
||||
|
||||
// Quarantined reports whether err is a quarantine refusal.
|
||||
func Quarantined(err error) bool {
|
||||
var pe *ProxyError
|
||||
return errors.As(err, &pe) && !pe.Published.IsZero()
|
||||
}
|
||||
|
||||
type UpstreamError struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
@@ -227,7 +227,7 @@ func TestHeadUpstreamUsesRemoteTimeout(t *testing.T) {
|
||||
r := models.Remote{Name: "slow-head", PackageType: models.PackageGeneric, BaseURL: slow.URL, UpstreamResponseHeaderTimeout: 1}
|
||||
|
||||
start := time.Now()
|
||||
_, err := (&Engine{}).headUpstreamOnce(context.Background(), r, "blob.bin", prov(t, models.PackageGeneric))
|
||||
_, err := (&Engine{}).headUpstreamOnce(context.Background(), r, "blob.bin", prov(t, models.PackageGeneric), ClassImmutable)
|
||||
if err == nil || time.Since(start) > 2*time.Second {
|
||||
t.Fatalf("HEAD ignored the remote's response header timeout: err=%v after %s", err, time.Since(start))
|
||||
}
|
||||
|
||||
@@ -271,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/cache"
|
||||
"git.unkin.net/unkin/artifactapi/pkg/models"
|
||||
goredis "github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
func must[T any](v T, err error) T {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func quarantineUpstream(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/fresh.tgz":
|
||||
w.Header().Set("Last-Modified", time.Now().Add(-time.Hour).UTC().Format(http.TimeFormat))
|
||||
case "/old.tgz":
|
||||
w.Header().Set("Last-Modified", time.Now().Add(-30*24*time.Hour).UTC().Format(http.TimeFormat))
|
||||
case "/future.tgz":
|
||||
w.Header().Set("Last-Modified", time.Now().Add(30*24*time.Hour).UTC().Format(http.TimeFormat))
|
||||
case "/undated.tgz", "/pkg":
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte("body " + r.URL.Path))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func quarantineRemote(t *testing.T, name string, enabled bool) models.Remote {
|
||||
r := models.Remote{Name: name, PackageType: models.PackageNPM, RepoType: models.RepoTypeRemote,
|
||||
BaseURL: quarantineUpstream(t).URL, QuarantineEnabled: enabled, QuarantineDays: 3}
|
||||
return seed(t, r)
|
||||
}
|
||||
|
||||
func wantQuarantined(t *testing.T, err error) *ProxyError {
|
||||
t.Helper()
|
||||
var pe *ProxyError
|
||||
if !errors.As(err, &pe) || pe.Status != http.StatusForbidden || !Quarantined(err) {
|
||||
t.Fatalf("want quarantine 403, got %v", err)
|
||||
}
|
||||
return pe
|
||||
}
|
||||
|
||||
func TestQuarantineBlocksFreshArtifact(t *testing.T) {
|
||||
requireStack(t)
|
||||
ctx := context.Background()
|
||||
r := quarantineRemote(t, "q-fresh", true)
|
||||
pe := wantQuarantined(t, func() error { _, err := testEngine.Fetch(ctx, r, "fresh.tgz", prov(t, models.PackageNPM)); return err }())
|
||||
if d := time.Until(pe.Available); d < 70*time.Hour || d > 72*time.Hour {
|
||||
t.Errorf("available in %s, want ~71h", d)
|
||||
}
|
||||
if a, _ := testDB.GetArtifact(ctx, r.Name, "fresh.tgz"); a != nil {
|
||||
t.Error("quarantined artifact was cached")
|
||||
}
|
||||
_, err := testEngine.Head(ctx, r, "fresh.tgz", prov(t, models.PackageNPM))
|
||||
_ = wantQuarantined(t, err)
|
||||
}
|
||||
|
||||
func TestQuarantineServesOldArtifact(t *testing.T) {
|
||||
requireStack(t)
|
||||
r := quarantineRemote(t, "q-old", true)
|
||||
res, err := testEngine.Fetch(context.Background(), r, "old.tgz", prov(t, models.PackageNPM))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := readAll(t, res); got != "body /old.tgz" {
|
||||
t.Errorf("body = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuarantineFallsBackToFirstSeen(t *testing.T) {
|
||||
requireStack(t)
|
||||
ctx := context.Background()
|
||||
r := quarantineRemote(t, "q-undated", true)
|
||||
p := prov(t, models.PackageNPM)
|
||||
_, err := testEngine.Fetch(ctx, r, "undated.tgz", p)
|
||||
first := wantQuarantined(t, err).Published
|
||||
if time.Since(first) > time.Minute {
|
||||
t.Fatalf("first seen %s, want now", first)
|
||||
}
|
||||
_, err = testEngine.Fetch(ctx, r, "undated.tgz", p)
|
||||
if again := wantQuarantined(t, err).Published; !again.Equal(first) {
|
||||
t.Errorf("first seen moved: %s -> %s", first, again)
|
||||
}
|
||||
|
||||
r.QuarantineDays = 1
|
||||
c := goredis.NewClient(must(goredis.ParseURL(testRedis)))
|
||||
defer func() { _ = c.Close() }()
|
||||
if err := c.Set(ctx, "firstseen:q-undated:undated.tgz", time.Now().Add(-48*time.Hour).Unix(), time.Hour).Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := testEngine.Fetch(ctx, r, "undated.tgz", p); err != nil {
|
||||
t.Errorf("seen 48h ago with 1 day quarantine: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuarantineSkipsMutable(t *testing.T) {
|
||||
requireStack(t)
|
||||
r := quarantineRemote(t, "q-mutable", true)
|
||||
if _, err := testEngine.Fetch(context.Background(), r, "pkg", prov(t, models.PackageNPM)); err != nil {
|
||||
t.Errorf("mutable index blocked: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuarantineDisabled(t *testing.T) {
|
||||
requireStack(t)
|
||||
r := quarantineRemote(t, "q-off", false)
|
||||
if _, err := testEngine.Fetch(context.Background(), r, "fresh.tgz", prov(t, models.PackageNPM)); err != nil {
|
||||
t.Errorf("disabled quarantine blocked: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuarantineKeepsServingCached(t *testing.T) {
|
||||
requireStack(t)
|
||||
ctx := context.Background()
|
||||
r := quarantineRemote(t, "q-cached", false)
|
||||
p := prov(t, models.PackageNPM)
|
||||
if _, err := testEngine.Fetch(ctx, r, "fresh.tgz", p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.QuarantineEnabled = true
|
||||
expire(t, r.Name, "fresh.tgz")
|
||||
if _, err := testEngine.Fetch(ctx, r, "fresh.tgz", p); err != nil {
|
||||
t.Errorf("artifact cached before quarantine refused on refetch: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuarantineCapsFutureLastModified(t *testing.T) {
|
||||
requireStack(t)
|
||||
r := quarantineRemote(t, "q-future", true)
|
||||
_, err := testEngine.Fetch(context.Background(), r, "future.tgz", prov(t, models.PackageNPM))
|
||||
pe := wantQuarantined(t, err)
|
||||
if d := time.Until(pe.Available); d < 71*time.Hour || d > 72*time.Hour {
|
||||
t.Errorf("available in %s, want ~72h", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuarantineRedisErrorUnavailable(t *testing.T) {
|
||||
requireStack(t)
|
||||
ctx := context.Background()
|
||||
r := quarantineRemote(t, "q-redis-down", true)
|
||||
r.StaleOnError = true
|
||||
down := must(cache.NewRedis(testRedis))
|
||||
_ = down.Close()
|
||||
eng := NewEngine(testDB, down, testEngine.store)
|
||||
_, err := eng.Fetch(ctx, r, "undated.tgz", prov(t, models.PackageNPM))
|
||||
var pe *ProxyError
|
||||
if !errors.As(err, &pe) || pe.Status != http.StatusServiceUnavailable || pe.RetryAfter == 0 || Quarantined(err) {
|
||||
t.Fatalf("want 503 with Retry-After, got %#v", err)
|
||||
}
|
||||
if a, _ := testDB.GetArtifact(ctx, r.Name, "undated.tgz"); a != nil {
|
||||
t.Error("artifact cached while quarantine check unavailable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuarantineStaleOnErrorServesNothingUncached(t *testing.T) {
|
||||
requireStack(t)
|
||||
ctx := context.Background()
|
||||
r := quarantineRemote(t, "q-stale", true)
|
||||
r.StaleOnError = true
|
||||
p := prov(t, models.PackageNPM)
|
||||
_, err := testEngine.Fetch(ctx, r, "fresh.tgz", p)
|
||||
_ = wantQuarantined(t, err)
|
||||
r.BaseURL = "http://127.0.0.1:1"
|
||||
if _, err := testEngine.Fetch(ctx, r, "fresh.tgz", p); err == nil {
|
||||
t.Error("stale_on_error served an uncached quarantined path")
|
||||
}
|
||||
if a, _ := testDB.GetArtifact(ctx, r.Name, "fresh.tgz"); a != nil {
|
||||
t.Error("quarantined artifact was cached")
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -78,3 +79,98 @@ func TestServerOIDCWriteAuth(t *testing.T) {
|
||||
expect(204, "DELETE", "/api/v2/virtuals/srv-oidc-v", "", good)
|
||||
expect(204, "DELETE", "/api/v2/remotes/srv-oidc", "", good)
|
||||
}
|
||||
|
||||
func TestServerAuthUploads(t *testing.T) {
|
||||
requireStack(t)
|
||||
iss := testsupport.NewOIDCIssuer()
|
||||
defer iss.Close()
|
||||
|
||||
cfg := *testSrv.cfg
|
||||
cfg.OIDCIssuer = iss.URL
|
||||
cfg.OIDCAudiences = []string{"artifactapi"}
|
||||
cfg.AuthUploads = true
|
||||
s := *testSrv
|
||||
s.cfg = &cfg
|
||||
ts := httptest.NewServer(s.routes())
|
||||
defer ts.Close()
|
||||
|
||||
good := iss.Token(nil, "artifactapi", "ci", time.Hour)
|
||||
do := func(method, path, body, authz string) int {
|
||||
t.Helper()
|
||||
rq, _ := http.NewRequest(method, ts.URL+path, strings.NewReader(body))
|
||||
rq.Header.Set("Content-Type", "application/json")
|
||||
if authz != "" {
|
||||
rq.Header.Set("Authorization", authz)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(rq)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", method, path, err)
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
return resp.StatusCode
|
||||
}
|
||||
bearer := "Bearer " + good
|
||||
for _, typ := range []string{"generic", "docker", "npm", "gems"} {
|
||||
body := `{"name":"srv-up-` + typ + `","package_type":"` + typ + `","repo_type":"local"}`
|
||||
if got := do("POST", "/api/v2/remotes", body, bearer); got != 201 {
|
||||
t.Fatalf("create %s: status %d", typ, got)
|
||||
}
|
||||
}
|
||||
|
||||
writes := []struct{ method, path, body string }{
|
||||
{"PUT", "/api/v2/remotes/srv-up-generic/files/a.bin", "payload"},
|
||||
{"DELETE", "/api/v2/remotes/srv-up-generic/files/a.bin", ""},
|
||||
{"DELETE", "/api/v2/remotes/srv-up-generic/objects/a.bin", ""},
|
||||
{"DELETE", "/api/v2/locals/srv-up-generic/objects/a.bin", ""},
|
||||
{"PUT", "/api/v1/local/srv-up-npm/pkg", "{}"},
|
||||
{"POST", "/api/v1/local/srv-up-gems/api/v1/gems", "x"},
|
||||
{"DELETE", "/api/v1/local/srv-up-gems/api/v1/gems/yank", ""},
|
||||
{"POST", "/v2/srv-up-docker/img/blobs/uploads/", ""},
|
||||
{"PATCH", "/v2/srv-up-docker/img/blobs/uploads/x", ""},
|
||||
{"PUT", "/v2/srv-up-docker/img/manifests/t", "{}"},
|
||||
{"DELETE", "/v2/srv-up-docker/img/manifests/t", ""},
|
||||
}
|
||||
for _, w := range writes {
|
||||
if got := do(w.method, w.path, w.body, ""); got != 401 {
|
||||
t.Errorf("anonymous %s %s: status %d, want 401", w.method, w.path, got)
|
||||
}
|
||||
}
|
||||
|
||||
if got := do("PUT", "/api/v2/remotes/srv-up-generic/files/a.bin", "payload", bearer); got != 201 {
|
||||
t.Errorf("bearer upload: status %d", got)
|
||||
}
|
||||
if got := do("PUT", "/api/v2/remotes/srv-up-generic/files/b.bin", "payload", basic(good)); got != 201 {
|
||||
t.Errorf("basic upload: status %d", got)
|
||||
}
|
||||
if got := do("POST", "/v2/srv-up-docker/img/blobs/uploads/", "", basic(good)); got != 202 {
|
||||
t.Errorf("basic docker upload: status %d", got)
|
||||
}
|
||||
expectOpen := func(method, path string) {
|
||||
t.Helper()
|
||||
if got := do(method, path, "", ""); got >= 400 {
|
||||
t.Errorf("anonymous %s %s: status %d", method, path, got)
|
||||
}
|
||||
}
|
||||
expectOpen("GET", "/api/v2/remotes/srv-up-generic/files/a.bin")
|
||||
expectOpen("GET", "/api/v2/remotes/srv-up-generic/objects")
|
||||
expectOpen("GET", "/api/v1/local/srv-up-generic/a.bin")
|
||||
expectOpen("GET", "/v2/token")
|
||||
if got := do("GET", "/v2/", "", ""); got != 401 {
|
||||
t.Errorf("anonymous ping: status %d, want 401 challenge", got)
|
||||
}
|
||||
if got := do("POST", "/api/v2/probe", `{}`, ""); got == 401 {
|
||||
t.Error("probe gated")
|
||||
}
|
||||
|
||||
if got := do("DELETE", "/api/v2/remotes/srv-up-generic/files/a.bin", "", bearer); got >= 400 {
|
||||
t.Errorf("bearer delete: status %d", got)
|
||||
}
|
||||
for _, typ := range []string{"generic", "docker", "npm", "gems"} {
|
||||
do("DELETE", "/api/v2/remotes/srv-up-"+typ, "", bearer)
|
||||
}
|
||||
}
|
||||
|
||||
func basic(tok string) string {
|
||||
return "Basic " + base64.StdEncoding.EncodeToString([]byte("ci:"+tok))
|
||||
}
|
||||
|
||||
@@ -168,8 +168,18 @@ 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
|
||||
r.Mount("/api/v1", proxyHandler.Routes())
|
||||
r.Mount("/v2", proxyHandler.DockerV2Routes())
|
||||
s.virtEngine.AnsibleLocal = proxyHandler.LocalAnsible
|
||||
writeAuth := auth.NewBearerOIDC(s.cfg.OIDCIssuer, s.cfg.OIDCAudiences)
|
||||
uploadAuth := passthrough
|
||||
dockerV2 := http.Handler(proxyHandler.DockerV2Routes())
|
||||
if s.cfg.AuthUploads {
|
||||
uploadAuth = writeAuth.RequireWrites
|
||||
dockerV2 = writeAuth.RequireRegistryWrites(dockerV2, func(r *http.Request) string {
|
||||
return v1.Scheme(r) + "://" + r.Host + "/v2/token"
|
||||
})
|
||||
}
|
||||
r.Mount("/api/v1", uploadAuth(proxyHandler.Routes()))
|
||||
r.Mount("/v2", dockerV2)
|
||||
|
||||
remotesHandler := v2.NewRemotesHandler(s.db, s.cache, map[models.PackageType]v2.Primer{
|
||||
models.PackageGitHubRPM: s.syncer,
|
||||
@@ -181,7 +191,6 @@ func (s *Server) routes() chi.Router {
|
||||
statsHandler := v2.NewStatsHandler(s.db)
|
||||
eventsHandler := v2.NewEventsHandler()
|
||||
probeHandler := v2.NewProbeHandler(s.engine, s.db)
|
||||
writeAuth := auth.NewBearerOIDC(s.cfg.OIDCIssuer, s.cfg.OIDCAudiences)
|
||||
|
||||
r.Route("/api/v2", func(r chi.Router) {
|
||||
r.Mount("/remotes", writeAuth.RequireWrites(remotesHandler.Routes()))
|
||||
@@ -191,19 +200,19 @@ func (s *Server) routes() chi.Router {
|
||||
r.Mount("/events", eventsHandler.Routes())
|
||||
r.Mount("/probe", probeHandler.Routes())
|
||||
|
||||
r.Route("/remotes/{name}/objects", func(r chi.Router) {
|
||||
r.With(uploadAuth).Route("/remotes/{name}/objects", func(r chi.Router) {
|
||||
objHandler := v2.NewObjectsHandler(s.db)
|
||||
r.Get("/", objHandler.Routes(s.engine).ServeHTTP)
|
||||
r.Delete("/*", objHandler.Routes(s.engine).ServeHTTP)
|
||||
})
|
||||
|
||||
r.Route("/locals/{name}/objects", func(r chi.Router) {
|
||||
r.With(uploadAuth).Route("/locals/{name}/objects", func(r chi.Router) {
|
||||
objHandler := v2.NewObjectsHandler(s.db)
|
||||
r.Get("/", objHandler.LocalRoutes().ServeHTTP)
|
||||
r.Delete("/*", objHandler.LocalRoutes().ServeHTTP)
|
||||
})
|
||||
|
||||
r.Route("/remotes/{name}/files", func(r chi.Router) {
|
||||
r.With(uploadAuth).Route("/remotes/{name}/files", func(r chi.Router) {
|
||||
r.Put("/*", s.localHandler.Routes().ServeHTTP)
|
||||
r.Get("/*", s.localHandler.Routes().ServeHTTP)
|
||||
r.Delete("/*", s.localHandler.Routes().ServeHTTP)
|
||||
@@ -213,6 +222,8 @@ func (s *Server) routes() chi.Router {
|
||||
return r
|
||||
}
|
||||
|
||||
func passthrough(h http.Handler) http.Handler { return h }
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user