Compare commits

..

27 Commits

Author SHA1 Message Date
unkin-agent d41902a5fa Merge master
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
2026-10-25 19:07:51 +11:00
unkin-agent ee55ef3fcd Add per-remote sync_interval and spread GitHub syncs (#166)
ci/woodpecker/tag/docker Pipeline was successful
github_* remotes sharing a mutable_ttl all become due at the same moment, so many remotes created or synced together burst GitHub at once.

- add per-remote `sync_interval` (seconds) for github_rpm/github_deb/github_alpine remotes
- offset each remote's slots by a stable name-hashed phase so remotes on one interval spread out
- compute slots and the first-sync gate in the lease claim from the DB clock so replicas agree
- wait for the first slot on a scheduled first sync and skip the create-time prime; cold-cache primes stay immediate
- keep the mutable_ttl freshness predicate unchanged when `sync_interval` is unset
- document `sync_interval` in README, rpm docs and UI types

Reviewed-on: #166
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-10-25 18:58:22 +11:00
unkin-agent e5a50315c3 Merge master
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
2026-10-25 15:48:07 +11:00
unkin-agent e5313342ba Serve stale virtual members while they refresh (#167)
Virtual merges expire after 60s and the next request blocks on every member. One slow member (a large github_rpm remote) makes the re-merge outlast dnf's 30s timeout.

- add `serve_stale` to remotes (model, v2 API, migration 0008, UI, TUI), default false
- serve a flagged member's last good fetch to rpm/deb/alpine merges at once
- refresh it in the background, single-flighted, detached from the request
- block on the member again once its copy is over an hour old, or the flag is off
- prune copies with their merge cache; drop refreshes for evicted entries

Reviewed-on: #167
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-10-25 15:44:16 +11:00
unkin-agent f0ee593164 Gate local repo writes behind OIDC tokens (#169)
Local repo writes (file uploads/deletes, object evictions, native publishes, docker push) are open to anyone who can reach the service; only remote/virtual config is token-gated.

- add `ARTIFACTAPI_AUTH_UPLOADS` (default false) gating every local write path behind the OIDC verifier
- accept the token as Bearer, Token, Basic password (any user) or bare header value on every gated path, including remote/virtual config writes
- serve a `/v2/token` Bearer challenge flow so `docker login` with the token enables push; missing, expired or invalid credentials get an anonymous token so pulls stay open
- document minting a Vault identity token and passing it per client

Reviewed-on: #169
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-10-25 15:17:32 +11:00
unkin-agent 6cf84c4f6c Enforce remote quarantine for fresh immutable artifacts (#170)
Remotes store `quarantine_enabled`/`quarantine_days` but the proxy never enforces them, so freshly published (possibly malicious) artifacts are cached immediately.

- refuse uncached immutable paths published under `quarantine_days` ago with a JSON 403
- take publish time from `Last-Modified` capped at now, else a Redis first-seen key; 503 + `Retry-After` when that lookup fails
- reject enabled quarantine with `quarantine_days<1`; backfill stored `<=0` to 3
- default `quarantine_days` to 3 on create and keep it on update
- let goproxy virtual probes skip a quarantining member

Reviewed-on: #170
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-10-25 15:16:23 +11:00
unkin-agent cfb1f93d48 Add parallel_fetch toggle for virtual member index files (#165)
Virtual members already merge concurrently, but each rpm member fetches primary/filelists/other one after another, and each deb member its Packages files the same way. On large virtuals the slowest member's serial chain pushes the merge past dnf's 30s timeout.

- add `parallel_fetch` (default false) to virtuals: model, v2 API, migration 0007
- fetch a member's index files concurrently (up to 4) when set; first error still fails the member
- thread the flag through rpm and deb member fetches

Reviewed-on: #165
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-10-25 15:13:57 +11:00
unkin-agent e6375011dc Harden github_rpm repodata cache error, 404 and store ordering
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
2026-10-25 13:37:03 +11:00
unkin-agent 817b246eaf Evict idle repodata cache entries and tighten fingerprint tests
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
2026-10-25 13:26:21 +11:00
unkin-agent 5d50eda58a Add cache_repodata toggle for github_rpm remotes
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
Cache generated repodata per replica and rebuild only when the remote's
rpm_metadata fingerprint (count, max id) changes.
2026-10-25 13:16:28 +11:00
unkin-agent 9294686ea6 Add alpine virtual repositories (#164)
ci/woodpecker/tag/docker Pipeline was successful
Virtual repos cover rpm, deb, npm and others but not alpine, so a local alpine repo and github_alpine remotes cannot be served as one apk repository.

- merge members' `<dir>/APKINDEX.tar.gz` per directory; member order wins duplicate (P, V, A); a member 404 is skipped
- 302 `<dir>/<P>-<V>.apk` downloads to the member whose index listed the package
- serve the merged index unsigned, like local and github_alpine indexes
- cache merges per directory on the shared virtual cache; member errors fail with 502
- add docs/alpine/virtual.md, unit tests and an apk e2e with abuild-built packages

Requires unkin/artifactapi#163.

Reviewed-on: #164
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-10-24 18:35:52 +11:00
unkin-agent fe9de81afa Drop local files and metadata with their remote (#163)
Deleting a repo leaves its local_files and package metadata rows behind, so a repo recreated under the same name inherits stale index entries and re-uploading any path 409s.

- delete local_files and rpm/deb/alpine/puppet/helm metadata rows with the remote in one transaction
- poll TestLocalAlpineIndex until the index lists the package, since metadata is parsed after the upload returns

Reviewed-on: #163
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-10-24 18:35:28 +11:00
unkin-agent 2905ec99e4 Add vcs remote type (#162)
ci/woodpecker/tag/docker Pipeline was successful
The generic `github` remote only allowlists URL patterns: every repo needs hand-written mutable patterns for branch archives, and a moved branch re-downloads the whole archive under a ref-named key.

- add `vcs` package type for GitHub or Gitea (picked from `base_url`) tag, commit and release-download paths, cached immutable
- redirect `archive/refs/heads/<branch>` to the commit archive, resolving the branch via the branch API with one shared, per-remote bounded lookup cache reused for `mutable_ttl`
- cache missing branches for at least 60s and map API rate limits to `503` with `Retry-After`
- reject any other path shape, escaping segments and denied redirect targets
- add `docs/vcs/remote.md`, a UI type def and a vcs e2e caching case

Reviewed-on: #162
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-10-24 17:21:05 +11:00
unkin-agent 7fa5434dbc Add deb virtual repositories (#138)
ci/woodpecker/tag/docker Pipeline was successful
Virtual repos merge helm, pypi, rpm, npm, gems, ansible, goproxy and puppet but not deb, so several apt repos (e.g. github_deb remotes plus a local deb) cannot be served as one apt source.

- merge member Packages for flat (`./`) and `dists/<suite>` layouts; member order wins duplicate (Package, Version, Architecture)
- generate an unsigned Release with Acquire-By-Hash, serving indexes by name and by SHA256 from both retained generations
- prefix Filename with the member name and 302 downloads to the member route
- cache merges per suite on the shared virtual cache, capped at 32 suites per virtual (LRU)
- add docs/deb/virtual.md, unit tests and dockerised apt e2e for flat and suite layouts

Reviewed-on: #138
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-10-24 16:21:33 +11:00
unkin-agent ca269c5046 Forward module and pagination params on puppet release listings (#161)
ci/woodpecker/tag/docker Pipeline was successful
A puppet remote drops the query string on `/v3/releases`, so `?module=<slug>` reaches the Forge unfiltered and clients get releases of every module instead of the one they asked for.

- forward validated `module`, `limit` (capped at 100) and `offset` on `v3/releases` via `QueryForwarder`
- document the forwarded params and the Forge API `base_url` in `docs/puppet/remote.md`

Reviewed-on: #161
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-10-20 22:19:23 +11:00
unkin-agent 3da8545464 Add puppet virtual repositories (#160)
Clients need one Forge to install internal modules and forgeapi.puppet.com ones with dependencies resolved across both; puppet has no virtual type.

- merge member `/v3/releases?module=` lists newest first, the earlier member winning a duplicate version
- cache merges, and modules no member has, for the merge TTL
- paginate over the merged list with Forge-root-relative links and `file_uri`
- serve `/v3/modules/<slug>` with the highest merged version as `current_release`
- 302 release details and downloads to the owning member; fail closed with 502 on a member error
- add docs/puppet/virtual.md and an e2e-docker case driving real `puppet module install` and r10k

Reviewed-on: #160
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-10-20 22:19:03 +11:00
unkin-agent 4649878a6d Add goproxy virtual repositories (#159)
ci/woodpecker/tag/docker Pipeline was successful
Go clients need one GOPROXY for internal and public modules; goproxy has no virtual type, so a local goproxy repo and proxy.golang.org cannot be served together.

- union member `@v/list`s; compute `@latest` over the union (highest release, else highest prerelease)
- 302 `@latest` and `.info`/`.mod`/`.zip` to the first member with that version, root-relative and `no-store`; probe `.info` for unlisted pseudo-versions
- validate case-encoded module paths and versions
- fail closed with 502 on a member error, skip members answering 404/410
- cache merged lists, keyed on list and version owners, and module misses for the merge TTL
- add `docs/goproxy/virtual.md` and a stock `golang:1.25` e2e-docker case

requires unkin/artifactapi#143

Reviewed-on: #159
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-10-19 21:39:49 +11:00
unkin-agent 29e4777eec Add goproxy local repositories (#143)
Private Go modules have nowhere to live: goproxy is remote-only, so internal modules need VCS access or a hand-rolled proxy.

- serve local goproxy repos over the GOPROXY protocol (`@v/list`, `@latest`, `.info`/`.mod`/`.zip`) with case-encoded module paths
- accept PUT uploads of `.zip`/`.mod`/`.info` per version; synthesize `.info` when absent
- validate zips with `golang.org/x/mod/zip.CheckZip`, `.mod` module path and `.info` version before storing
- add a ContentValidator hook so providers can check upload bytes
- document publishing in the UI and `docs/goproxy/local.md`; add an e2e-docker case

Reviewed-on: #143
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-10-19 00:01:50 +11:00
unkin-agent 0cfde41612 Add ansible virtual repositories (#158)
ci/woodpecker/tag/docker Pipeline was successful
Clients need one Galaxy server to install both internal collections and galaxy.ansible.com ones; ansible has no virtual type.

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

requires unkin/artifactapi#157

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

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

requires unkin/artifactapi#137

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

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

requires unkin/artifactapi#146

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

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

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

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

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

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

Reviewed-on: #156
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-10-18 02:32:40 +11:00
unkin-agent f9791d4b08 Keep a tombstone on gem yank (#155)
ci/woodpecker/tag/docker Pipeline was successful
Yank hard-deleted a local gem version, so the same name-version-platform could be re-pushed with different bytes and break lockfiles pinning the old checksum. rubygems.org forbids re-pushing a yanked version.

- write a `_yanked/<name>/<full>` tombstone before deleting the gem, quick spec and index line
- reject a push of a tombstoned version with 409
- serialise push and yank of one version on the engine Redis lock; 503 when it stays busy
- drop partial push writes on failure
- return 404 for `_index/` and `_yanked/` paths on gems locals
- encode out-of-range gemspec dates (before 1900) as the epoch instead of wrapping the Time#_dump year field
- extend the ruby:3.3 e2e to yank, re-push different bytes and fail to install the yanked version

Reviewed-on: #155
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-10-18 02:08:46 +11:00
unkin-agent 18c58dc9c7 Add gems virtual repositories (#154)
Bundler needs one source for internal and public gems, but gems virtuals have no merger, so a local gems repo and rubygems.org cannot be served together.

- merge `info/<gem>` across members (member order wins a duplicate version-platform); pass a single-member gem through verbatim
- regenerate `versions` lines and md5s for gems several members list; union `names`
- 302 `gems/*.gem` and `quick/Marshal.4.8/*` to the member owning that version, via the shared member-path validation
- return 502 on a member error; cache merges in the shared virtual cache; route HEAD on virtuals
- add `docs/gems/virtual.md` and a stock `ruby:3.3` bundler e2e-docker case

Reviewed-on: #154
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-10-18 02:07:52 +11:00
unkin-agent 7dd7fc70d1 Add local gems repositories (#153)
In-house gems have nowhere to publish: gems locals only take raw file uploads, which `gem push`, `gem install` and bundler cannot use.

- accept `gem push` (`POST <repo>/api/v1/gems`, 256MiB cap, 8MiB metadata cap) and `gem yank` (`DELETE <repo>/api/v1/gems/yank`); any API key works
- parse the gemspec from the `.gem`; reject an existing name-version-platform with 409
- serve the compact index (`versions`, `info/<gem>`, `names`), `.gem` files and generated `quick/Marshal.4.8` specs; no legacy `specs.4.8.gz`
- route HEAD on locals (gem probes `versions` with HEAD)
- add `docs/gems/local.md`, a UI snippet and a stock `ruby:3.3` e2e-docker case

Reviewed-on: #153
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-10-18 01:12:37 +11:00
148 changed files with 11893 additions and 334 deletions
+12 -7
View File
@@ -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)
- `goproxy`: [remote](docs/goproxy/remote.md)
- `gems`: [remote](docs/gems/remote.md), [local](docs/gems/local.md), [virtual](docs/gems/virtual.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
@@ -131,6 +134,7 @@ No patterns + no blocklist = open proxy. Provider handles mutability classificat
GET /api/v1/remote/{name}/{path} Proxy/cache artifact
GET /api/v1/virtual/{name}/{path} Virtual repo (merged index)
PUT /api/v1/local/{name}/{package} npm publish (local npm repos)
POST/DELETE /api/v1/local/{name}/api/v1/gems[/yank] gem push / yank (local gems repos)
GET /v2/{name}/{path} Docker Registry v2
```
@@ -199,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
+30
View File
@@ -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.
+26
View File
@@ -0,0 +1,26 @@
# Local ansible repository
A local `ansible` repo is a Galaxy v3 server: `ansible-galaxy collection publish`
and `ansible-galaxy collection install` work against `/api/v1/local/<repo>`.
`publish` needs an API key; any value works 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.
+11
View File
@@ -0,0 +1,11 @@
# `ansible` remote
Shared classification, TTL and eviction rules: [caching](../common/caching.md).
| Type | Mutable (auto-detected) | Immutable (auto-detected) |
|---|---|---|
| `ansible` | Galaxy `api/` JSON (links rewritten, `limit`/`offset` forwarded) | `artifacts/*.tar.gz` |
```bash
ansible-galaxy collection install community.general -s https://artifactapi.example/api/v1/remote/galaxy/api/
```
+28
View File
@@ -0,0 +1,28 @@
# `ansible` virtual
An ansible virtual serves one Galaxy v3 API over local and remote ansible
members: `ansible-galaxy collection install` works against
`/api/v1/virtual/<name>/`.
- `versions/` concatenates member version lists in member order; on a
duplicate version the earlier member wins. Pagination (`limit` up to 100,
`offset`) runs over the merged list.
- `versions/<version>/` is the owning member's detail with `href` and
`download_url` pointing at the virtual; the download 302s to that member.
- A member error fails the request with 502; a member without the collection
is skipped. Merges are cached for 60s.
```hcl
resource "artifactapi_virtual" "ansible" {
name = "ansible"
package_type = "ansible"
members = [artifactapi_local_ansible.internal.name, artifactapi_remote_ansible.galaxy.name]
}
```
```bash
ansible-galaxy collection install unkin.hello community.general \
--server https://artifactapi.example.com/api/v1/virtual/ansible/
```
Publish to the local member directly; the virtual is read-only.
+53
View File
@@ -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.
+22 -3
View File
@@ -15,7 +15,8 @@ Every proxied request on a remote is classified as **denied**, **immutable** or
| `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. |
| `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 +53,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 +112,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).
+32
View File
@@ -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.
+3
View File
@@ -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
+24
View File
@@ -0,0 +1,24 @@
# Local gems repository
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 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
gem install mygem --source https://artifactapi.example.com/api/v1/local/<repo>/
gem yank mygem -v 1.0.0 --host https://artifactapi.example.com/api/v1/local/<repo>
```
```ruby
# Gemfile
source "https://artifactapi.example.com/api/v1/local/<repo>/"
gem "mygem"
```
The repo serves the compact index (`versions`, `info/<gem>`, `names`), `gems/*.gem`
and `quick/Marshal.4.8/*.gemspec.rz`. The legacy `specs.4.8.gz` indexes are not
served; current gem and bundler clients use the compact index.
Versions are immutable: pushing an existing name-version-platform is rejected
with 409. A yanked version disappears from the index and its files are removed.
+28
View File
@@ -0,0 +1,28 @@
# `gems` virtual
A gems virtual serves one RubyGems source over local and remote gems members:
`gem install` and `bundle install` work against `/api/v1/virtual/<name>/`.
- `info/<gem>` is the union of member lines; on a duplicate version-platform
the earlier member wins. A gem only one member has is served verbatim.
- `versions` regenerates the line (and md5) of every gem several members list
from its merged `info`; other gems' lines pass through. `names` is the union.
- `gems/*.gem` and `quick/Marshal.4.8/*.gemspec.rz` 302 to the member whose
`info` line won.
- A member error fails the request with 502; a member without the gem is
skipped. Merges are cached for 60s.
```hcl
resource "artifactapi_virtual" "gems" {
name = "gems"
package_type = "gems"
members = [artifactapi_local_gems.internal.name, artifactapi_remote_gems.rubygems.name]
}
```
```ruby
# Gemfile
source "https://artifactapi.example.com/api/v1/virtual/gems/"
```
Push to the local member directly; the virtual is read-only.
+20
View File
@@ -0,0 +1,20 @@
# Local Go module proxy
A local `goproxy` repo serves private modules over the GOPROXY protocol
(`@v/list`, `@latest`, `@v/<ver>.info|.mod|.zip`). Publish a version by
uploading its files under the case-encoded module path (uppercase as `!lower`):
```sh
for ext in zip mod info; do
curl -fsSL --upload-file v0.1.0.$ext \
https://artifactapi.k8s.syd1.au.unkin.net/api/v2/remotes/go-internal/files/example.unkin.net/hello/@v/v0.1.0.$ext
done
GOPROXY=https://artifactapi.k8s.syd1.au.unkin.net/api/v1/local/go-internal,https://proxy.golang.org,direct \
GONOSUMDB=example.unkin.net go get example.unkin.net/hello@latest
```
Build the zip with `golang.org/x/mod/zip` or reuse the files `go mod download`
leaves in `$(go env GOMODCACHE)/cache/download`. Uploads are validated: zips must
pass `zip.CheckZip` (every file under `module@version/`), `.mod` must declare the
module, and `.info` must match the version. `.info` is optional; it is
synthesized from the version when absent. `@v/list` lists versions with a `.mod`.
+30
View File
@@ -0,0 +1,30 @@
# `goproxy` virtual
A goproxy virtual serves one GOPROXY over local and remote goproxy members:
`GOPROXY=https://artifactapi.example.com/api/v1/virtual/<name>`.
- `<module>/@v/list` is the union of member versions, sorted by semver.
- `<module>/@latest` is the highest release in that union, else the highest
prerelease; with no listed versions it falls back to the first member that
answers `@latest`.
- `@latest` and `@v/<version>.{info,mod,zip}` 302 to the first member, in
member order, that lists the version (or, for unlisted pseudo-versions,
serves its `.info`). Redirects are root-relative and `no-store`.
- Module paths are case-encoded (uppercase as `!lower`).
- A member error fails the request with 502; a member answering 404 or 410 is
skipped. Merged lists are cached for 60s.
```hcl
resource "artifactapi_virtual" "go" {
name = "go"
package_type = "goproxy"
members = [artifactapi_local_goproxy.internal.name, artifactapi_remote_goproxy.goproxy.name]
}
```
```sh
export GOPROXY=https://artifactapi.example.com/api/v1/virtual/go GONOSUMDB=example.unkin.net
go get example.unkin.net/hello@latest golang.org/x/text@latest
```
Publish to the local member directly; the virtual is read-only.
+2 -1
View File
@@ -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
+3
View File
@@ -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`.
+37
View File
@@ -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.
+16 -3
View File
@@ -32,8 +32,12 @@ 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
- **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
@@ -50,7 +54,7 @@ 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`
~once per `sync_interval` (else `mutable_ttl`) regardless of replica count, and the shared `etag`
lets any replica issue the conditional request.
```hcl
@@ -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,11 @@ 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.
+29
View File
@@ -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
```
+12 -2
View File
@@ -29,10 +29,20 @@ already-running stack.
generic (upload/download), pypi (wheel + generated `simple/`
index), rpm (real package + **automatic repodata** generation), npm (stock
`node:22` client: `npm publish` unscoped + scoped, `npm install` + `require()`,
second version, republish rejected).
second version, republish rejected), gems (stock `ruby:3.3`: `gem push`,
`gem install`, `bundle install` + `require`, republish rejected, `gem yank`,
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).
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); 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,
+105
View File
@@ -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)
}
}
+66
View File
@@ -0,0 +1,66 @@
//go:build dockere2e
package e2edocker
import (
"fmt"
"os"
"os/exec"
"strings"
"testing"
"time"
)
// ansibleHelloScript builds unkin.e2e_hello 1.0.0 with one module into /w/dist.
const ansibleHelloScript = `pip install -q ansible-core >/dev/null 2>&1
mkdir -p /w && cd /w && ansible-galaxy collection init unkin.e2e_hello >/dev/null
cd unkin/e2e_hello && sed -i 's/^version: .*/version: 1.0.0/' galaxy.yml && mkdir -p plugins/modules
printf '%s\n' 'DOCUMENTATION = r"""' 'module: hello' 'short_description: e2e greeting' 'description: Returns a greeting.' 'options:' ' name: {type: str, default: world, description: Who to greet.}' '"""' \
'from ansible.module_utils.basic import AnsibleModule' \
'm = AnsibleModule(argument_spec=dict(name=dict(type="str", default="world")))' \
'm.exit_json(changed=False, msg="hello " + m.params["name"])' > plugins/modules/hello.py
ansible-galaxy collection build --output-path /w/dist >/dev/null
`
// ansibleHelloPlay runs unkin.e2e_hello.hello from collections in /w/coll.
const ansibleHelloPlay = `export ANSIBLE_COLLECTIONS_PATH=/w/coll
ansible-doc unkin.e2e_hello.hello | grep -q 'Returns a greeting'
printf -- '- hosts: localhost\n gather_facts: false\n tasks:\n - unkin.e2e_hello.hello: {name: e2e}\n register: r\n - debug: {msg: "PLAY {{ r.msg }}"}\n' > /w/play.yml
ansible-playbook -i localhost, -c local /w/play.yml | grep -o 'PLAY hello e2e' | head -1
`
// TestLocalAnsiblePublishInstall builds a collection with a stock
// ansible-galaxy client, publishes it to a local ansible repo, rejects the
// republish, installs it back and runs its module.
func TestLocalAnsiblePublishInstall(t *testing.T) {
network := os.Getenv("COMPOSE_NETWORK")
internal := os.Getenv("ARTIFACTAPI_INTERNAL")
if network == "" || internal == "" {
t.Skip("COMPOSE_NETWORK/ARTIFACTAPI_INTERNAL not set; run via scripts/docker-e2e.sh")
}
if _, err := exec.LookPath("docker"); err != nil {
t.Skip("docker not available on the test host")
}
name := fmt.Sprintf("e2e-ansible-local-%d", time.Now().UnixNano())
createRepo(t, fmt.Sprintf(`{"name":%q,"package_type":"ansible","repo_type":"local"}`, name))
defer deleteRepo(t, name)
script := "set -euo pipefail\n" + ansibleHelloScript + `
ansible-galaxy collection publish /w/dist/unkin-e2e_hello-1.0.0.tar.gz --server "$SRV" --api-key unused
if ansible-galaxy collection publish /w/dist/unkin-e2e_hello-1.0.0.tar.gz --server "$SRV" --api-key unused; then echo "republish accepted"; exit 1; fi
ansible-galaxy collection install unkin.e2e_hello:==1.0.0 --server "$SRV" -p /w/coll
` + ansibleHelloPlay
srv := strings.TrimRight(internal, "/") + "/api/v1/local/" + name + "/"
out, err := exec.Command("docker", "run", "--rm", "--network", network,
"-e", "SRV="+srv, "python:3.12-slim", "bash", "-c", script).CombinedOutput()
if err != nil {
t.Fatalf("ansible-galaxy client run failed: %v\n%s", err, out)
}
for _, want := range []string{"successfully published and imported", "HTTP Code: 409", "unkin.e2e_hello:1.0.0 was installed successfully", "PLAY hello e2e"} {
if !strings.Contains(string(out), want) {
t.Fatalf("missing %q in client output:\n%s", want, out)
}
}
}
+64
View File
@@ -0,0 +1,64 @@
//go:build dockere2e
package e2edocker
import (
"fmt"
"os"
"os/exec"
"strings"
"testing"
"time"
)
// TestVirtualAnsibleInstall publishes a collection to the second of two
// ansible locals and installs it with stock ansible-galaxy through a virtual
// over both, the download 302ing to the owning member.
func TestVirtualAnsibleInstall(t *testing.T) {
network := os.Getenv("COMPOSE_NETWORK")
internal := os.Getenv("ARTIFACTAPI_INTERNAL")
if network == "" || internal == "" {
t.Skip("COMPOSE_NETWORK/ARTIFACTAPI_INTERNAL not set; run via scripts/docker-e2e.sh")
}
if _, err := exec.LookPath("docker"); err != nil {
t.Skip("docker not available on the test host")
}
id := time.Now().UnixNano()
a, b, virt := fmt.Sprintf("e2e-ansible-a-%d", id), fmt.Sprintf("e2e-ansible-b-%d", id), fmt.Sprintf("e2e-ansible-v-%d", id)
for _, name := range []string{a, b} {
createRepo(t, fmt.Sprintf(`{"name":%q,"package_type":"ansible","repo_type":"local"}`, name))
defer deleteRepo(t, name)
}
createVirtual(t, fmt.Sprintf(`{"name":%q,"package_type":"ansible","members":[%q,%q]}`, virt, a, b))
defer deleteVirtual(t, virt)
base := strings.TrimRight(internal, "/")
script := "set -euo pipefail\n" + ansibleHelloScript + `
ansible-galaxy collection publish /w/dist/unkin-e2e_hello-1.0.0.tar.gz --server "$BASE/api/v1/local/$B/" --api-key unused
V="$BASE/api/v1/virtual/$VIRT"
ansible-galaxy collection install unkin.e2e_hello --server "$V/" -p /w/coll
DL=$(python3 -c 'import json,sys,urllib.request as u;print(json.load(u.urlopen(sys.argv[1]))["download_url"])' "$V/api/v3/collections/unkin/e2e_hello/versions/1.0.0/")
echo "REDIRECT $(python3 -c 'import sys,urllib.request as u
class N(u.HTTPRedirectHandler):
def redirect_request(self,*a): return None
try: u.build_opener(N).open(sys.argv[1])
except u.HTTPError as e: print(e.code, e.headers["Location"])' "$DL")"
` + ansibleHelloPlay
out, err := exec.Command("docker", "run", "--rm", "--network", network,
"-e", "BASE="+base, "-e", "B="+b, "-e", "VIRT="+virt,
"python:3.12-slim", "bash", "-c", script).CombinedOutput()
if err != nil {
t.Fatalf("ansible-galaxy client run failed: %v\n%s", err, out)
}
for _, want := range []string{
"unkin.e2e_hello:1.0.0 was installed successfully",
"REDIRECT 302 " + base + "/api/v1/local/" + b + "/artifacts/unkin-e2e_hello-1.0.0.tar.gz",
"PLAY hello e2e",
} {
if !strings.Contains(string(out), want) {
t.Fatalf("missing %q in client output:\n%s", want, out)
}
}
}
+7
View File
@@ -0,0 +1,7 @@
//go:build dockere2e
package e2edocker
func init() {
cachingCases = append(cachingCases, cachingCase{"ansible", "galaxy/artifacts/ns-col-1.0.0.tar.gz", "galaxy/artifacts/ns-col-1.0.0.tar.gz"})
}
+7
View File
@@ -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"})
}
+115
View File
@@ -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)
}
}
+67
View File
@@ -0,0 +1,67 @@
//go:build dockere2e
package e2edocker
import (
"fmt"
"os"
"os/exec"
"strings"
"testing"
"time"
)
// TestLocalGemsPushInstall drives stock gem and bundler clients against a
// local gems repo: push two versions, reject a republish, gem install and
// bundle install the latest via the compact index, then yank it and check a
// re-push of the yanked version with different bytes is rejected.
func TestLocalGemsPushInstall(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-gems-local-%d", time.Now().UnixNano())
createRepo(t, fmt.Sprintf(`{"name":%q,"package_type":"gems","repo_type":"local"}`, name))
defer deleteRepo(t, name)
src := strings.TrimRight(internal, "/") + "/api/v1/local/" + name
script := `set -euo pipefail
export GEM_HOST_API_KEY=unused
mk() { d=/w/src-$1${2:+-b}; mkdir -p $d/lib; cd $d
echo "module UnkinE2eHello; VERSION = '$1'; end${2:-}" > lib/unkin-e2e-hello.rb
printf 'Gem::Specification.new { |s| s.name = "unkin-e2e-hello"; s.version = "%s"; s.summary = "e2e"; s.authors = ["e2e"]; s.files = ["lib/unkin-e2e-hello.rb"] }' "$1" > g.gemspec
gem build g.gemspec >/dev/null 2>&1; }
mk 0.1.0 && gem push --host "$SRC" unkin-e2e-hello-0.1.0.gem
mk 0.2.0 && gem push --host "$SRC" unkin-e2e-hello-0.2.0.gem
if gem push --host "$SRC" unkin-e2e-hello-0.2.0.gem; then echo "republish accepted"; exit 1; fi
mkdir -p /w/app && cd /w/app
printf -- "---\n:sources:\n- $SRC/\n" > ~/.gemrc
gem install --no-document unkin-e2e-hello
ruby -e 'require "unkin-e2e-hello"; puts "GEM #{UnkinE2eHello::VERSION}"'
gem uninstall -a -x unkin-e2e-hello >/dev/null
printf 'source "%s/"\ngem "unkin-e2e-hello"\n' "$SRC" > Gemfile
bundle install
bundle exec ruby -e 'require "unkin-e2e-hello"; puts "BUNDLE #{UnkinE2eHello::VERSION}"'
gem yank unkin-e2e-hello -v 0.2.0 --host "$SRC"
echo "INFO $(curl -s "$SRC/info/unkin-e2e-hello" | cut -d' ' -f1 | tr '\n' ' ')"
mk 0.2.0 '# changed' && if gem push --host "$SRC" unkin-e2e-hello-0.2.0.gem; then echo "yanked re-push accepted"; exit 1; fi
cd /w/app && gem uninstall -a -x unkin-e2e-hello >/dev/null
if gem install --remote --no-document unkin-e2e-hello -v 0.2.0; then echo "yanked version installed"; exit 1; fi
echo "VERSIONS $(curl -s "$SRC/versions" | grep '^unkin-e2e-hello ' | cut -d' ' -f2)"`
out, err := exec.Command("docker", "run", "--rm", "--network", network,
"-e", "SRC="+src, "ruby:3.3", "bash", "-c", script).CombinedOutput()
if err != nil {
t.Fatalf("gem client run failed: %v\n%s", err, out)
}
for _, want := range []string{"GEM 0.2.0", "BUNDLE 0.2.0", "INFO --- 0.1.0 ", "re-pushing a yanked version is not allowed", "VERSIONS 0.1.0\n"} {
if !strings.Contains(string(out), want) {
t.Fatalf("missing %q in gem output:\n%s", want, out)
}
}
}
+69
View File
@@ -0,0 +1,69 @@
//go:build dockere2e
package e2edocker
import (
"fmt"
"os"
"os/exec"
"strings"
"testing"
"time"
)
// TestVirtualGemsInstall pushes to two local gems repos and drives stock
// bundler through a virtual over both: merged versions, member order winning a
// duplicate version, and downloads redirected to the owning member.
func TestVirtualGemsInstall(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-gems-a-%d", id), fmt.Sprintf("e2e-gems-b-%d", id), fmt.Sprintf("e2e-gems-v-%d", id)
for _, name := range []string{a, b} {
createRepo(t, fmt.Sprintf(`{"name":%q,"package_type":"gems","repo_type":"local"}`, name))
defer deleteRepo(t, name)
}
createVirtual(t, fmt.Sprintf(`{"name":%q,"package_type":"gems","members":[%q,%q]}`, virt, a, b))
defer deleteVirtual(t, virt)
base := strings.TrimRight(internal, "/")
script := `set -euo pipefail
export GEM_HOST_API_KEY=unused
pub() { d=/w/src-$1-$2; mkdir -p $d/lib; cd $d
echo "module UnkinE2eHello; VERSION = '$1:$2'; end" > lib/unkin-e2e-hello.rb
printf 'Gem::Specification.new { |s| s.name = "unkin-e2e-hello"; s.version = "%s"; s.summary = "e2e"; s.authors = ["e2e"]; s.files = ["lib/unkin-e2e-hello.rb"] }' "$2" > g.gemspec
gem build g.gemspec >/dev/null 2>&1; gem push --host "$BASE/api/v1/local/$1" unkin-e2e-hello-$2.gem; }
pub "$A" 0.1.0
pub "$B" 0.1.0
pub "$B" 0.2.0
V="$BASE/api/v1/virtual/$V"
echo "VERSIONS $(curl -s "$V/versions" | grep '^unkin-e2e-hello ' | cut -d' ' -f2)"
mkdir -p /w/app && cd /w/app
printf 'source "%s/"\ngem "unkin-e2e-hello", "0.1.0"\n' "$V" > Gemfile
bundle install
bundle exec ruby -e 'require "unkin-e2e-hello"; puts "REQ #{UnkinE2eHello::VERSION}"'
echo "REDIRECT $(curl -s -o /dev/null -w '%{redirect_url}' "$V/gems/unkin-e2e-hello-0.2.0.gem")"`
out, err := exec.Command("docker", "run", "--rm", "--network", network,
"-e", "BASE="+base, "-e", "A="+a, "-e", "B="+b, "-e", "V="+virt,
"ruby:3.3", "bash", "-c", script).CombinedOutput()
if err != nil {
t.Fatalf("bundler run failed: %v\n%s", err, out)
}
for _, want := range []string{
"VERSIONS 0.1.0,0.2.0",
"REQ " + a + ":0.1.0",
"REDIRECT " + base + "/api/v1/local/" + b + "/gems/unkin-e2e-hello-0.2.0.gem",
} {
if !strings.Contains(string(out), want) {
t.Fatalf("missing %q in bundler output:\n%s", want, out)
}
}
}
+57
View File
@@ -0,0 +1,57 @@
//go:build dockere2e
package e2edocker
import (
"bytes"
"net/http"
"os"
"path/filepath"
"testing"
"golang.org/x/mod/module"
modzip "golang.org/x/mod/zip"
)
// TestLocalGoProxy publishes two module versions and reads them back over the
// GOPROXY protocol.
func TestLocalGoProxy(t *testing.T) {
createRepo(t, `{"name":"local-go","package_type":"goproxy","repo_type":"local"}`)
defer deleteRepo(t, "local-go")
const mod = "example.unkin.net/!hello"
gomod := []byte("module example.unkin.net/Hello\n\ngo 1.25\n")
var zip010 []byte
for _, ver := range []string{"v0.1.0", "v0.2.0"} {
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "go.mod"), gomod, 0o600)
os.WriteFile(filepath.Join(dir, "hello.go"), []byte("package hello\n"), 0o600)
var buf bytes.Buffer
if err := modzip.CreateFromDir(&buf, module.Version{Path: "example.unkin.net/Hello", Version: ver}, dir); err != nil {
t.Fatal(err)
}
if ver == "v0.1.0" {
zip010 = buf.Bytes()
}
uploadFile(t, "local-go", mod+"/@v/"+ver+".zip", buf.Bytes(), "application/zip")
uploadFile(t, "local-go", mod+"/@v/"+ver+".mod", gomod, "text/plain")
}
resp, body := doRequest(t, http.MethodPut, api("/api/v2/remotes/local-go/files/"+mod+"/@v/v0.3.0.zip"), []byte("junk"), "application/zip")
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("invalid zip: status %d: %s", resp.StatusCode, body)
}
for path, want := range map[string]string{
"/@v/list": "v0.1.0\nv0.2.0\n",
"/@latest": `{"Version":"v0.2.0"}`,
"/@v/v0.1.0.info": `{"Version":"v0.1.0"}`,
"/@v/v0.1.0.mod": string(gomod),
"/@v/v0.1.0.zip": string(zip010),
} {
resp, body := doRequest(t, http.MethodGet, api("/api/v1/local/local-go/"+mod+path), nil, "")
if resp.StatusCode != http.StatusOK || string(body) != want {
t.Errorf("%s: status %d body %q", path, resp.StatusCode, body)
}
}
}
+81
View File
@@ -0,0 +1,81 @@
//go:build dockere2e
package e2edocker
import (
"bytes"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"golang.org/x/mod/module"
modzip "golang.org/x/mod/zip"
)
// TestVirtualGoProxy publishes to two local goproxy repos and drives a stock go
// client through a virtual over both: merged versions, member order winning a
// duplicate version, and version files redirected to the owning member.
func TestVirtualGoProxy(t *testing.T) {
network := os.Getenv("COMPOSE_NETWORK")
internal := os.Getenv("ARTIFACTAPI_INTERNAL")
if network == "" || internal == "" {
t.Skip("COMPOSE_NETWORK/ARTIFACTAPI_INTERNAL not set; run via scripts/docker-e2e.sh")
}
if _, err := exec.LookPath("docker"); err != nil {
t.Skip("docker not available on the test host")
}
id := time.Now().UnixNano()
a, b, virt := fmt.Sprintf("e2e-go-a-%d", id), fmt.Sprintf("e2e-go-b-%d", id), fmt.Sprintf("e2e-go-v-%d", id)
for _, name := range []string{a, b} {
createRepo(t, fmt.Sprintf(`{"name":%q,"package_type":"goproxy","repo_type":"local"}`, name))
defer deleteRepo(t, name)
}
createVirtual(t, fmt.Sprintf(`{"name":%q,"package_type":"goproxy","members":[%q,%q]}`, virt, a, b))
defer deleteVirtual(t, virt)
const mod = "example.unkin.net/hello"
gomod := []byte("module " + mod + "\n\ngo 1.25\n")
for _, p := range [][2]string{{a, "v0.1.0"}, {b, "v0.1.0"}, {b, "v0.2.0"}} {
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "go.mod"), gomod, 0o600)
os.WriteFile(filepath.Join(dir, "hello.go"), []byte("package hello\n\nconst Greeting = \""+p[0]+":"+p[1]+"\"\n"), 0o600)
var buf bytes.Buffer
if err := modzip.CreateFromDir(&buf, module.Version{Path: mod, Version: p[1]}, dir); err != nil {
t.Fatal(err)
}
uploadFile(t, p[0], mod+"/@v/"+p[1]+".zip", buf.Bytes(), "application/zip")
uploadFile(t, p[0], mod+"/@v/"+p[1]+".mod", gomod, "text/plain")
}
base := strings.TrimRight(internal, "/")
script := `set -euo pipefail
export GOPROXY="$BASE/api/v1/virtual/$V" GONOSUMDB=example.unkin.net GOFLAGS=-mod=mod GOTOOLCHAIN=local
mkdir -p /w && cd /w
printf 'module consumer\n\ngo 1.25\n' > go.mod
printf 'package main\n\nimport "example.unkin.net/hello"\n\nfunc main() { println("RUN " + hello.Greeting) }\n' > main.go
echo "VERSIONS $(go list -m -versions example.unkin.net/hello)"
go get example.unkin.net/hello@latest && go run . 2>&1
go get example.unkin.net/hello@v0.1.0 && go run . 2>&1
echo "REDIRECT $(curl -s -o /dev/null -w '%{redirect_url}' "$GOPROXY/example.unkin.net/hello/@v/v0.1.0.zip")"`
out, err := exec.Command("docker", "run", "--rm", "--network", network,
"-e", "BASE="+base, "-e", "V="+virt, "golang:1.25", "bash", "-c", script).CombinedOutput()
if err != nil {
t.Fatalf("go run failed: %v\n%s", err, out)
}
for _, want := range []string{
"VERSIONS example.unkin.net/hello v0.1.0 v0.2.0",
"RUN " + b + ":v0.2.0",
"RUN " + a + ":v0.1.0",
"REDIRECT " + base + "/api/v1/local/" + a + "/example.unkin.net/hello/@v/v0.1.0.zip",
} {
if !strings.Contains(string(out), want) {
t.Fatalf("missing %q in go output:\n%s", want, out)
}
}
}
+30 -24
View File
@@ -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)
}
}
}
+7 -2
View File
@@ -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)
+83
View File
@@ -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)
}
}
}
+1
View File
@@ -20,6 +20,7 @@ require (
github.com/testcontainers/testcontainers-go/modules/redis v0.42.0
github.com/ulikunitz/xz v0.5.16
golang.org/x/crypto v0.54.0
golang.org/x/mod v0.37.0
golang.org/x/sync v0.22.0
golang.org/x/time v0.15.0
gopkg.in/yaml.v3 v3.0.1
+4
View File
@@ -227,6 +227,8 @@ golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
@@ -247,6 +249,8 @@ golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+289
View File
@@ -0,0 +1,289 @@
package v1
import (
"bytes"
"cmp"
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"slices"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"git.unkin.net/unkin/artifactapi/internal/database"
"git.unkin.net/unkin/artifactapi/internal/provider/ansible"
"git.unkin.net/unkin/artifactapi/internal/virtual"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
var ansibleMaxPublishBytes int64 = 256 << 20
// ansibleError writes a Galaxy v3 error document, which ansible-galaxy prints.
func ansibleError(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]any{"errors": []map[string]string{{
"status": strconv.Itoa(status), "code": strings.ToLower(strings.ReplaceAll(http.StatusText(status), " ", "_")),
"title": http.StatusText(status), "detail": msg,
}}})
}
func writeAnsibleJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Vary", "Host, X-Forwarded-Proto")
w.WriteHeader(status)
enc := json.NewEncoder(w)
enc.SetEscapeHTML(false)
_ = enc.Encode(v)
}
func isAnsiblePublish(path string) bool {
return strings.TrimSuffix(path, "/")+"/" == ansible.PublishPath
}
func (h *ProxyHandler) handleAnsiblePublish(w http.ResponseWriter, r *http.Request) {
remote, err := h.db.GetRemote(r.Context(), chi.URLParam(r, "localName"))
if err != nil || remote.RepoType != models.RepoTypeLocal || remote.PackageType != models.PackageAnsible {
ansibleError(w, http.StatusMethodNotAllowed, "publish is only supported on local ansible repositories")
return
}
base, ok := requestBaseURL(w, r)
if !ok {
return
}
r.Body = http.MaxBytesReader(w, r.Body, ansibleMaxPublishBytes)
file, sum, err := readAnsibleUpload(r)
if err != nil {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
ansibleError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("upload exceeds %d bytes", ansibleMaxPublishBytes))
return
}
ansibleError(w, http.StatusBadRequest, err.Error())
return
}
digest := sha256.Sum256(file)
if got := hex.EncodeToString(digest[:]); sum != "" && !strings.EqualFold(sum, got) {
ansibleError(w, http.StatusBadRequest, "sha256 does not match the uploaded file")
return
}
e, err := ansible.ParseCollection(bytes.NewReader(file))
if err != nil {
ansibleError(w, http.StatusBadRequest, err.Error())
return
}
e.SHA256, e.Size, e.CreatedAt = hex.EncodeToString(digest[:]), int64(len(file)), time.Now().UTC()
id := e.Namespace + "." + e.Name + ":" + e.Version
unlock, err := h.engine.Lock(r.Context(), remote.Name, "_ansiblelock/"+e.TaskID(), gemsLockWait, 5*time.Minute)
if err != nil {
ansibleError(w, http.StatusServiceUnavailable, id+" is being published, retry")
return
}
defer unlock()
conflict := func() {
ansibleError(w, http.StatusConflict, id+" already exists; overwrites are not allowed")
}
if f, err := h.db.GetLocalFile(r.Context(), remote.Name, e.IndexPath()); err != nil {
ansibleError(w, http.StatusInternalServerError, err.Error())
return
} else if f != nil {
conflict()
return
}
// The version is not live, so a tarball left by a failed publish is safe to drop.
artifact := ansible.ArtifactPrefix + e.Filename()
fail := func(status int, msg string) {
if err := h.db.DeleteLocalFile(context.WithoutCancel(r.Context()), remote.Name, artifact); err != nil {
slog.Error("ansible publish cleanup failed", "repo", remote.Name, "path", artifact, "error", err)
}
ansibleError(w, status, msg)
}
if ok, err := h.npmStoreImmutable(r.Context(), remote.Name, artifact, file, "application/gzip"); err != nil {
fail(http.StatusInternalServerError, err.Error())
return
} else if !ok {
fail(http.StatusConflict, id+" already exists; overwrites are not allowed")
return
}
entry, err := json.Marshal(e)
if err != nil {
fail(http.StatusInternalServerError, err.Error())
return
}
// The index entry gates visibility, so it is written last.
if _, err := h.npmStore(r.Context(), remote.Name, e.IndexPath(), entry, "application/json", false); err != nil {
if errors.Is(err, database.ErrAlreadyExists) {
conflict()
return
}
fail(http.StatusInternalServerError, err.Error())
return
}
slog.Info("local ansible collection published", "repo", remote.Name, "collection", id)
repoBase := base + "/api/v1/local/" + url.PathEscape(remote.Name)
writeAnsibleJSON(w, http.StatusAccepted, map[string]string{"task": repoBase + "/api/v3/imports/collections/" + e.TaskID() + "/"})
}
// readAnsibleUpload returns the multipart "file" part, which ansible-galaxy
// base64-encodes, and the optional "sha256" field sent with it.
func readAnsibleUpload(r *http.Request) ([]byte, string, error) {
mr, err := r.MultipartReader()
if err != nil {
return nil, "", errors.New("expected a multipart/form-data upload")
}
var file []byte
var sum string
for {
part, err := mr.NextPart()
if err == io.EOF {
break
}
if err != nil {
return nil, "", err
}
switch part.FormName() {
case "file":
if file != nil {
return nil, "", errors.New("multiple file parts")
}
var src io.Reader = part
switch enc := strings.ToLower(part.Header.Get("Content-Transfer-Encoding")); enc {
case "base64":
src = base64.NewDecoder(base64.StdEncoding, part)
case "", "binary", "7bit", "8bit":
default:
return nil, "", fmt.Errorf("unsupported Content-Transfer-Encoding %q", enc)
}
if file, err = io.ReadAll(src); err != nil {
return nil, "", err
}
case "sha256":
b, err := io.ReadAll(io.LimitReader(part, 128))
if err != nil {
return nil, "", err
}
sum = strings.TrimSpace(string(b))
}
}
if file == nil {
return nil, "", errors.New("missing file part")
}
return file, sum, nil
}
// serveLocalAnsible answers the Galaxy v3 API of a local ansible repo.
func (h *ProxyHandler) serveLocalAnsible(w http.ResponseWriter, r *http.Request, remote *models.Remote, ap ansible.APIPath) {
base, ok := requestBaseURL(w, r)
if !ok {
return
}
body, err := h.localAnsibleDoc(r.Context(), remote.Name, ap, r.URL.Query(), base+"/api/v1/local/"+url.PathEscape(remote.Name))
if errors.Is(err, virtual.ErrNotFound) {
ansibleError(w, http.StatusNotFound, "not found")
return
}
if err != nil {
ansibleError(w, http.StatusInternalServerError, err.Error())
return
}
w.Header().Set("X-Artifact-Source", "local")
writeAnsibleJSON(w, http.StatusOK, body)
}
// LocalAnsible serves a local ansible repo's collection, full versions list
// or version detail to ansible virtuals, with root-relative URLs.
func (h *ProxyHandler) LocalAnsible(ctx context.Context, repo, path string) ([]byte, error) {
doc, err := h.localAnsibleDoc(ctx, repo, ansible.ParseAPIPath(path), nil, "/api/v1/local/"+url.PathEscape(repo))
if err != nil {
return nil, err
}
return json.Marshal(doc)
}
// localAnsibleDoc builds the document for ap; a nil q lists every version.
func (h *ProxyHandler) localAnsibleDoc(ctx context.Context, repo string, ap ansible.APIPath, q url.Values, base string) (any, error) {
switch ap.Kind {
case ansible.APIRoot:
return ansible.Root(), nil
case ansible.APIImport:
ns, rest, _ := strings.Cut(ap.TaskID, "-")
name, version, _ := strings.Cut(rest, "-")
entries, err := h.localAnsibleEntries(ctx, repo, ns, name)
if err != nil {
return nil, err
}
for _, e := range entries {
if e.Version == version {
return ansible.ImportTask(e), nil
}
}
case ansible.APICollection, ansible.APIVersions, ansible.APIVersion:
entries, err := h.localAnsibleEntries(ctx, repo, ap.Namespace, ap.Name)
if err != nil || len(entries) == 0 {
return nil, cmp.Or(err, virtual.ErrNotFound)
}
refs := make([]ansible.VersionRef, len(entries))
for i, e := range entries {
refs[i] = e.Ref()
}
switch ap.Kind {
case ansible.APICollection:
return ansible.CollectionDoc(base, ap.Namespace, ap.Name, refs), nil
case ansible.APIVersions:
limit, offset := len(refs), 0
if q != nil {
limit, offset = ansible.Page(q)
}
return ansible.VersionsPage(base, ap.Namespace, ap.Name, refs, limit, offset), nil
}
for _, e := range entries {
if e.Version == ap.Version {
return ansible.VersionDoc(base, e), nil
}
}
}
return nil, virtual.ErrNotFound
}
// localAnsibleEntries returns a collection's versions, newest publish first.
func (h *ProxyHandler) localAnsibleEntries(ctx context.Context, repo, ns, name string) ([]ansible.Entry, error) {
if !ansible.NameRe.MatchString(ns) || !ansible.NameRe.MatchString(name) {
return nil, nil
}
prefix := ansible.IndexPrefix + ns + "/" + name + "/"
files, err := h.db.ListLocalFilesByPrefix(ctx, repo, prefix)
if err != nil {
return nil, err
}
slices.SortFunc(files, func(a, b database.LocalFile) int { return cmp.Compare(b.ID, a.ID) })
var out []ansible.Entry
for _, f := range files {
// LIKE treats '_' as a wildcard, so the prefix match can over-select.
if !strings.HasPrefix(f.FilePath, prefix) {
continue
}
raw, err := h.readLocalBlob(ctx, f.ContentHash)
if err != nil {
return nil, err
}
var e ansible.Entry
if err := json.Unmarshal(raw, &e); err != nil {
return nil, fmt.Errorf("decode %s: %w", f.FilePath, err)
}
out = append(out, e)
}
return out, nil
}
+275
View File
@@ -0,0 +1,275 @@
package v1
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"encoding/base64"
"encoding/json"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/textproto"
"strings"
"testing"
"git.unkin.net/unkin/artifactapi/internal/virtual"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
func testCollection(t *testing.T, ns, name, version string) []byte {
t.Helper()
manifest := `{"collection_info":{"namespace":"` + ns + `","name":"` + name + `","version":"` + version + `","dependencies":{"ansible.posix":">=1.0.0"}}}`
var buf bytes.Buffer
zw := gzip.NewWriter(&buf)
tw := tar.NewWriter(zw)
_ = tw.WriteHeader(&tar.Header{Name: "MANIFEST.json", Mode: 0o644, Size: int64(len(manifest))})
_, _ = tw.Write([]byte(manifest))
_ = tw.Close()
_ = zw.Close()
return buf.Bytes()
}
// ansiblePublish uploads like ansible-galaxy: the file part base64-encoded
// whenever a sha256 field is sent, raw otherwise.
func ansiblePublish(t *testing.T, url string, tarball []byte, sum string) (int, string) {
t.Helper()
var body bytes.Buffer
mw := multipart.NewWriter(&body)
hdr := textproto.MIMEHeader{"Content-Disposition": {`form-data; name="file"; filename="c.tar.gz"`}}
data := tarball
if sum != "" {
_ = mw.WriteField("sha256", sum)
hdr.Set("Content-Transfer-Encoding", "base64")
data = []byte(base64.StdEncoding.EncodeToString(tarball))
}
fw, _ := mw.CreatePart(hdr)
_, _ = fw.Write(data)
_ = mw.Close()
req, _ := http.NewRequest(http.MethodPost, url, &body)
req.Header.Set("Content-Type", mw.FormDataContentType())
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
var b bytes.Buffer
_, _ = b.ReadFrom(resp.Body)
return resp.StatusCode, b.String()
}
func getJSON(t *testing.T, url string, v any) {
t.Helper()
code, body := npmDo(t, http.MethodGet, url, "")
if code != http.StatusOK {
t.Fatalf("GET %s = %d: %s", url, code, body)
}
if err := json.Unmarshal([]byte(body), v); err != nil {
t.Fatalf("GET %s: %v: %s", url, err, body)
}
}
func TestAnsiblePublishAndServe(t *testing.T) {
h, srv := npmTestServer(t)
repo := npmRepo(t, h, models.PackageAnsible)
base := srv.URL + "/api/v1/local/" + repo + "/"
publish := base + "api/v3/artifacts/collections/"
v1 := testCollection(t, "unkin", "e2e_hello", "1.0.0")
code, body := ansiblePublish(t, publish, v1, sha256Hex(v1))
if code != http.StatusAccepted {
t.Fatalf("publish = %d: %s", code, body)
}
var task struct{ Task string }
_ = json.Unmarshal([]byte(body), &task)
if task.Task != base+"api/v3/imports/collections/unkin-e2e_hello-1.0.0/" {
t.Fatalf("task = %q", task.Task)
}
var st map[string]any
getJSON(t, task.Task, &st)
if st["state"] != "completed" || st["finished_at"] == "" {
t.Fatalf("task = %v", st)
}
if code, _ := npmDo(t, http.MethodGet, base+"api/v3/imports/collections/unkin-e2e_hello-9.9.9/", ""); code != http.StatusNotFound {
t.Errorf("unknown task = %d, want 404", code)
}
for _, v := range []string{"1.1.0", "2.0.0"} {
tb := testCollection(t, "unkin", "e2e_hello", v)
if code, body := ansiblePublish(t, publish, tb, ""); code != http.StatusAccepted {
t.Fatalf("publish %s = %d: %s", v, code, body)
}
}
if code, body := ansiblePublish(t, publish, v1, ""); code != http.StatusConflict || !strings.Contains(body, "already exists") {
t.Errorf("identical republish = %d: %s, want 409", code, body)
}
if code, _ := ansiblePublish(t, publish, testCollection(t, "unkin", "e2e_hello", "1.0.0")[:20], ""); code != http.StatusBadRequest {
t.Errorf("truncated tarball = %d, want 400", code)
}
var root map[string]map[string]string
getJSON(t, base+"api/", &root)
if root["available_versions"]["v3"] != "v3/" {
t.Fatalf("root = %v", root)
}
var page struct {
Meta struct{ Count int }
Links struct{ Next, Previous *string }
Data []struct{ Version, Href string }
}
getJSON(t, base+"api/v3/collections/unkin/e2e_hello/versions/?limit=2", &page)
if page.Meta.Count != 3 || len(page.Data) != 2 || page.Data[0].Version != "2.0.0" || page.Links.Next == nil || page.Links.Previous != nil {
t.Fatalf("page 1 = %+v", page)
}
next := *page.Links.Next
if !strings.HasPrefix(next, "/api/v1/local/"+repo+"/") {
t.Fatalf("next = %q, want root-relative", next)
}
next = srv.URL + next
page.Links.Next = nil
getJSON(t, next, &page)
if len(page.Data) != 1 || page.Data[0].Version != "1.0.0" || page.Links.Next != nil {
t.Fatalf("page 2 (%s) = %+v", next, page)
}
var detail struct {
DownloadURL string `json:"download_url"`
Artifact struct{ Sha256 string }
Metadata struct{ Dependencies map[string]string }
Namespace struct{ Name string }
Collection struct{ Name string }
}
getJSON(t, page.Data[0].Href, &detail)
if detail.DownloadURL != base+"artifacts/unkin-e2e_hello-1.0.0.tar.gz" || detail.Artifact.Sha256 != sha256Hex(v1) ||
detail.Metadata.Dependencies["ansible.posix"] != ">=1.0.0" || detail.Namespace.Name != "unkin" || detail.Collection.Name != "e2e_hello" {
t.Fatalf("detail = %+v", detail)
}
if code, got := npmDo(t, http.MethodGet, detail.DownloadURL, ""); code != http.StatusOK || got != string(v1) {
t.Fatalf("download = %d (%d bytes)", code, len(got))
}
var coll map[string]any
getJSON(t, base+"api/v3/collections/unkin/e2e_hello/", &coll)
if coll["versions_url"] != base+"api/v3/collections/unkin/e2e_hello/versions/" || coll["updated_at"] == "" {
t.Fatalf("collection = %v", coll)
}
for _, p := range []string{"api/v3/collections/unkin/nope/", "api/v3/collections/unkin/e2e_hello/versions/9.0.0/", "_index/unkin/e2e_hello/1.0.0", "other"} {
if code, _ := npmDo(t, http.MethodGet, base+p, ""); code != http.StatusNotFound {
t.Errorf("GET %s = %d, want 404", p, code)
}
}
}
func TestAnsiblePublishRejects(t *testing.T) {
h, srv := npmTestServer(t)
repo := npmRepo(t, h, models.PackageAnsible)
publish := srv.URL + "/api/v1/local/" + repo + "/api/v3/artifacts/collections/"
tb := testCollection(t, "unkin", "rej", "1.0.0")
if code, _ := ansiblePublish(t, publish, tb, strings.Repeat("0", 64)); code != http.StatusBadRequest {
t.Errorf("sha256 mismatch = %d, want 400", code)
}
if code, _ := npmDo(t, http.MethodPost, publish, "raw"); code != http.StatusBadRequest {
t.Errorf("non-multipart = %d, want 400", code)
}
old := ansibleMaxPublishBytes
ansibleMaxPublishBytes = 64
if code, _ := ansiblePublish(t, publish, tb, ""); code != http.StatusRequestEntityTooLarge {
t.Errorf("oversize = %d, want 413", code)
}
ansibleMaxPublishBytes = old
generic := npmRepo(t, h, models.PackageGeneric)
if code, _ := ansiblePublish(t, srv.URL+"/api/v1/local/"+generic+"/api/v3/artifacts/collections/", tb, ""); code != http.StatusMethodNotAllowed {
t.Errorf("publish to generic local = %d, want 405", code)
}
if code, _ := ansiblePublish(t, publish, tb, ""); code != http.StatusAccepted {
t.Errorf("publish after rejects = %d, want 202", code)
}
}
func TestAnsibleLocalPerHost(t *testing.T) {
h, srv := npmTestServer(t)
repo := npmRepo(t, h, models.PackageAnsible)
if code, b := ansiblePublish(t, srv.URL+"/api/v1/local/"+repo+"/api/v3/artifacts/collections/", testCollection(t, "unkin", "host", "1.0.0"), ""); code != http.StatusAccepted {
t.Fatalf("publish = %d: %s", code, b)
}
routes := h.Routes()
path := "/local/" + repo + "/api/v3/collections/unkin/host/versions/1.0.0/"
r := httptest.NewRequest(http.MethodGet, path, nil)
r.Host = "x@evil.com"
w := httptest.NewRecorder()
routes.ServeHTTP(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("bad Host = %d, want 400", w.Code)
}
r = httptest.NewRequest(http.MethodGet, path, nil)
r.Host = "aa.example"
r.Header.Set("X-Forwarded-Proto", "https")
w = httptest.NewRecorder()
routes.ServeHTTP(w, r)
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"download_url":"https://aa.example/api/v1/local/`+repo+`/artifacts/unkin-host-1.0.0.tar.gz"`) {
t.Fatalf("detail = %d: %s", w.Code, w.Body)
}
if cc, vary := w.Header().Get("Cache-Control"), w.Header().Get("Vary"); cc != "no-store" || vary != "Host, X-Forwarded-Proto" {
t.Errorf("Cache-Control %q Vary %q", cc, vary)
}
}
func TestAnsibleVirtualOverLocals(t *testing.T) {
h, srv := npmTestServer(t)
h.virtualEngine = virtual.NewEngine(h.db, nil)
h.virtualEngine.AnsibleLocal = h.LocalAnsible
a, b := npmRepo(t, h, models.PackageAnsible), npmRepo(t, h, models.PackageAnsible)
for _, p := range []struct{ repo, version string }{{a, "1.0.0"}, {b, "1.0.0"}, {b, "2.0.0"}} {
if code, body := ansiblePublish(t, srv.URL+"/api/v1/local/"+p.repo+"/api/v3/artifacts/collections/", testCollection(t, "unkin", "virt", p.version), ""); code != http.StatusAccepted {
t.Fatalf("publish %s to %s = %d: %s", p.version, p.repo, code, body)
}
}
virt := "v" + a
if err := h.db.CreateVirtual(context.Background(), &models.Virtual{Name: virt, PackageType: models.PackageAnsible, Members: []string{a, b}}); err != nil {
t.Fatal(err)
}
base := srv.URL + "/api/v1/virtual/" + virt + "/"
var page struct {
Meta struct{ Count int }
Links struct{ Next *string }
Data []struct{ Version, Href string }
}
getJSON(t, base+"api/v3/collections/unkin/virt/versions/?limit=1", &page)
if page.Meta.Count != 2 || len(page.Data) != 1 || page.Data[0].Version != "1.0.0" || page.Links.Next == nil {
t.Fatalf("page 1 = %+v", page)
}
href := page.Data[0].Href
if !strings.HasPrefix(*page.Links.Next, "/api/v1/virtual/"+virt+"/") {
t.Fatalf("next = %q, want root-relative", *page.Links.Next)
}
getJSON(t, srv.URL+*page.Links.Next, &page)
if len(page.Data) != 1 || page.Data[0].Version != "2.0.0" || page.Links.Next != nil {
t.Fatalf("page 2 = %+v", page)
}
var detail struct {
DownloadURL string `json:"download_url"`
}
getJSON(t, href, &detail)
if detail.DownloadURL != base+a+"/artifacts/unkin-virt-1.0.0.tar.gz" {
t.Fatalf("download_url = %q, want the first member via the virtual", detail.DownloadURL)
}
noFollow := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
resp, err := noFollow.Get(detail.DownloadURL)
if err != nil {
t.Fatal(err)
}
_ = resp.Body.Close()
if resp.StatusCode != http.StatusFound || resp.Header.Get("Location") != srv.URL+"/api/v1/local/"+a+"/artifacts/unkin-virt-1.0.0.tar.gz" {
t.Fatalf("download = %d -> %q", resp.StatusCode, resp.Header.Get("Location"))
}
for _, p := range []string{"api/v3/collections/unkin/nope/", "notamember/x.tar.gz"} {
if code, _ := npmDo(t, http.MethodGet, base+p, ""); code != http.StatusNotFound {
t.Errorf("GET %s = %d, want 404", p, code)
}
}
}
+64
View File
@@ -0,0 +1,64 @@
package v1
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"net/url"
"git.unkin.net/unkin/artifactapi/internal/provider/ansible"
"git.unkin.net/unkin/artifactapi/internal/virtual"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
// serveVirtualAnsible serves the merged Galaxy v3 API and 302s downloads to
// the member owning that version.
func (h *ProxyHandler) serveVirtualAnsible(w http.ResponseWriter, r *http.Request, virt models.Virtual, path, proxyBaseURL string) {
ap := ansible.ParseAPIPath(path)
base := proxyBaseURL + "/api/v1/virtual/" + url.PathEscape(virt.Name)
var doc any
var err error
switch ap.Kind {
case ansible.APIRoot:
doc = ansible.Root()
case ansible.APIVersion:
var body []byte
if body, err = h.virtualEngine.AnsibleVersion(r.Context(), virt, ap.Namespace, ap.Name, ap.Version, proxyBaseURL); err == nil {
doc = json.RawMessage(body)
}
case ansible.APICollection, ansible.APIVersions:
var d *virtual.AnsibleDoc
if d, err = h.virtualEngine.AnsibleVersions(r.Context(), virt, ap.Namespace, ap.Name); err == nil {
if ap.Kind == ansible.APICollection {
doc = ansible.CollectionDoc(base, ap.Namespace, ap.Name, d.Refs)
} else {
limit, offset := ansible.Page(r.URL.Query())
doc = ansible.VersionsPage(base, ap.Namespace, ap.Name, d.Refs, limit, offset)
}
}
default:
loc, ok, err := h.virtualEngine.MemberRedirect(r.Context(), virt, path, r.URL.RawQuery, proxyBaseURL)
switch {
case err != nil:
ansibleError(w, http.StatusBadRequest, err.Error())
case !ok:
ansibleError(w, http.StatusNotFound, "not found")
default:
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Vary", "Host, X-Forwarded-Proto")
http.Redirect(w, r, loc, http.StatusFound)
}
return
}
switch {
case errors.Is(err, virtual.ErrNotFound):
ansibleError(w, http.StatusNotFound, "not found")
case err != nil:
slog.Error("virtual ansible fetch failed", "virtual", virt.Name, "path", path, "error", err)
ansibleError(w, http.StatusBadGateway, "bad gateway")
default:
w.Header().Set("X-Artifact-Source", "virtual")
writeAnsibleJSON(w, http.StatusOK, doc)
}
}
+58
View File
@@ -0,0 +1,58 @@
package v1
import (
"net/url"
"strings"
"testing"
"git.unkin.net/unkin/artifactapi/internal/provider"
)
type plainProvider struct{ provider.Provider }
type forwardingProvider struct{ plainProvider }
func digits(v string) (string, bool) {
if v == "" || strings.Trim(v, "0123456789") != "" {
return "", false
}
return strings.TrimLeft(v, "0"), true
}
func (forwardingProvider) ForwardedQuery(path string) map[string]provider.QueryValidator {
if strings.HasSuffix(path, ".tar.gz") {
return nil
}
return map[string]provider.QueryValidator{"limit": digits, "offset": digits}
}
func TestForwardQuery(t *testing.T) {
tests := []struct {
name string
prov provider.Provider
path string
query string
want string
}{
{"non-forwarder keeps path", plainProvider{}, "a/versions/", "limit=10&offset=5", "a/versions/"},
{"no query", forwardingProvider{}, "a/versions/", "", "a/versions/"},
{"allowlisted params sorted", forwardingProvider{}, "a/versions/", "offset=5&limit=10", "a/versions/?limit=10&offset=5"},
{"unknown params dropped", forwardingProvider{}, "a/versions/", "x=1&limit=10&cb=zz", "a/versions/?limit=10"},
{"only unknown params", forwardingProvider{}, "a/versions/", "x=1", "a/versions/"},
{"first value wins", forwardingProvider{}, "a/versions/", "limit=10&limit=20", "a/versions/?limit=10"},
{"invalid value dropped", forwardingProvider{}, "a/versions/", "limit=1%202&offset=5", "a/versions/?offset=5"},
{"value canonicalised", forwardingProvider{}, "a/versions/", "limit=0010", "a/versions/?limit=10"},
{"path without allowlist", forwardingProvider{}, "a/b-1.0.0.tar.gz", "limit=10", "a/b-1.0.0.tar.gz"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
q, err := url.ParseQuery(tt.query)
if err != nil {
t.Fatal(err)
}
if got := forwardQuery(tt.prov, tt.path, q); got != tt.want {
t.Errorf("forwardQuery = %q, want %q", got, tt.want)
}
})
}
}
+312
View File
@@ -0,0 +1,312 @@
package v1
import (
"cmp"
"context"
"errors"
"fmt"
"io"
"log/slog"
"maps"
"net/http"
"net/url"
"slices"
"strings"
"time"
"github.com/go-chi/chi/v5"
"git.unkin.net/unkin/artifactapi/internal/database"
"git.unkin.net/unkin/artifactapi/internal/provider/gems"
"git.unkin.net/unkin/artifactapi/internal/virtual"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
// Local gems repos speak the RubyGems push/yank API and compact index. Per gem:
// gems/<full>.gem package (immutable)
// quick/Marshal.4.8/<full>.gemspec.rz marshalled spec (immutable)
// _index/<name>/<full> compact index /info line; gates visibility
// _yanked/<name>/<full> yank tombstone; blocks re-push of the version
var (
gemsMaxPushBytes int64 = 256 << 20
gemsLockWait = 30 * time.Second
)
const (
gemsIndexPrefix = "_index/"
gemsYankedPrefix = "_yanked/"
)
func gemsText(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(status)
_, _ = io.WriteString(w, msg)
}
func (h *ProxyHandler) localGemsRemote(w http.ResponseWriter, r *http.Request) (*models.Remote, bool) {
remote, err := h.db.GetRemote(r.Context(), chi.URLParam(r, "localName"))
if err != nil || remote.RepoType != models.RepoTypeLocal || remote.PackageType != models.PackageGems {
gemsText(w, http.StatusMethodNotAllowed, "only supported on local gems repositories")
return nil, false
}
return remote, true
}
// lockGemVersion serialises push and yank of one gem version across replicas.
func (h *ProxyHandler) lockGemVersion(w http.ResponseWriter, r *http.Request, repo, full string) (func(), bool) {
unlock, err := h.engine.Lock(r.Context(), repo, "_gemlock/"+full, gemsLockWait, 5*time.Minute)
if err != nil {
gemsText(w, http.StatusServiceUnavailable, full+" is being pushed or yanked, retry")
return nil, false
}
return unlock, true
}
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
}
remote, ok := h.localGemsRemote(w, r)
if !ok {
return
}
gem, err := io.ReadAll(http.MaxBytesReader(w, r.Body, gemsMaxPushBytes))
if err != nil {
gemsText(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("gem exceeds %d bytes", gemsMaxPushBytes))
return
}
spec, err := gems.ParseGem(gem)
if err != nil {
gemsText(w, http.StatusBadRequest, err.Error())
return
}
quick, err := gems.QuickSpec(spec)
if err != nil {
gemsText(w, http.StatusInternalServerError, err.Error())
return
}
full := spec.FullName()
unlock, ok := h.lockGemVersion(w, r, remote.Name, full)
if !ok {
return
}
defer unlock()
conflict := func() {
gemsText(w, http.StatusConflict, fmt.Sprintf("%s already exists; overwrites are not allowed", full))
}
index := gemsIndexPrefix + spec.Name + "/" + full
for _, p := range []string{gemsYankedPrefix + spec.Name + "/" + full, index} {
f, err := h.db.GetLocalFile(r.Context(), remote.Name, p)
if err != nil {
gemsText(w, http.StatusInternalServerError, err.Error())
return
}
if f != nil && p == index {
conflict()
return
}
if f != nil {
gemsText(w, http.StatusConflict, fmt.Sprintf("%s was yanked; re-pushing a yanked version is not allowed", full))
return
}
}
// The version is not live, so on failure every path is an invisible leftover and safe to drop.
paths := []string{"gems/" + full + ".gem", "quick/Marshal.4.8/" + full + ".gemspec.rz", index}
fail := func(status int, msg string) {
for _, p := range paths {
if err := h.db.DeleteLocalFile(context.WithoutCancel(r.Context()), remote.Name, p); err != nil {
slog.Error("gem push cleanup failed", "repo", remote.Name, "path", p, "error", err)
}
}
gemsText(w, status, msg)
}
// The index line gates visibility, so it is written last.
for _, f := range []struct {
path string
data []byte
}{
{paths[0], gem},
{paths[1], quick},
} {
ok, err := h.npmStoreImmutable(r.Context(), remote.Name, f.path, f.data, "application/octet-stream")
if err != nil {
fail(http.StatusInternalServerError, err.Error())
return
}
if !ok {
fail(http.StatusConflict, fmt.Sprintf("%s already exists; overwrites are not allowed", full))
return
}
}
line := gems.InfoLine(spec, gem)
if _, err := h.npmStore(r.Context(), remote.Name, index, []byte(line), "text/plain", false); err != nil {
fail(http.StatusInternalServerError, err.Error())
return
}
slog.Info("local gem pushed", "repo", remote.Name, "gem", full)
gemsText(w, http.StatusOK, "Successfully registered gem: "+spec.Name+" ("+spec.Version+")")
}
func (h *ProxyHandler) handleLocalDelete(w http.ResponseWriter, r *http.Request) {
if chi.URLParam(r, "*") != "api/v1/gems/yank" {
gemsText(w, http.StatusNotFound, "not found")
return
}
remote, ok := h.localGemsRemote(w, r)
if !ok {
return
}
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 64<<10))
if err != nil {
gemsText(w, http.StatusRequestEntityTooLarge, "yank body too large")
return
}
form, err := url.ParseQuery(string(body))
if err != nil {
gemsText(w, http.StatusBadRequest, "invalid form body")
return
}
for k, v := range r.URL.Query() {
if form.Get(k) == "" {
form[k] = v
}
}
spec := &gems.Spec{Name: form.Get("gem_name"), Version: form.Get("version"), Platform: form.Get("platform")}
if spec.Platform == "" {
spec.Platform = "ruby"
}
if !gems.NameRe.MatchString(spec.Name) || !gems.VersionRe.MatchString(spec.Version) || !gems.PlatformRe.MatchString(spec.Platform) {
gemsText(w, http.StatusBadRequest, "invalid gem_name, version or platform")
return
}
full := spec.FullName()
unlock, ok := h.lockGemVersion(w, r, remote.Name, full)
if !ok {
return
}
defer unlock()
index := gemsIndexPrefix + spec.Name + "/" + full
existing, err := h.db.GetLocalFile(r.Context(), remote.Name, index)
if err != nil {
gemsText(w, http.StatusInternalServerError, err.Error())
return
}
if existing == nil {
gemsText(w, http.StatusNotFound, "The version "+spec.Version+" does not exist")
return
}
// The tombstone keeps the yanked line and is written before any delete.
line, err := h.readLocalBlob(r.Context(), existing.ContentHash)
if err != nil {
gemsText(w, http.StatusInternalServerError, err.Error())
return
}
if _, err := h.npmStore(r.Context(), remote.Name, gemsYankedPrefix+spec.Name+"/"+full, line, "text/plain", false); err != nil && !errors.Is(err, database.ErrAlreadyExists) {
gemsText(w, http.StatusInternalServerError, err.Error())
return
}
for _, p := range []string{index, "gems/" + full + ".gem", "quick/Marshal.4.8/" + full + ".gemspec.rz"} {
if err := h.db.DeleteLocalFile(context.WithoutCancel(r.Context()), remote.Name, p); err != nil {
gemsText(w, http.StatusInternalServerError, err.Error())
return
}
}
slog.Info("local gem yanked", "repo", remote.Name, "gem", full)
gemsText(w, http.StatusOK, "Successfully deleted gem: "+spec.Name+" ("+spec.Version+")")
}
// isGemsIndexPath reports whether path is a compact index document.
func isGemsIndexPath(path string) bool {
return path == "versions" || path == "names" || strings.HasPrefix(path, "info/")
}
func (h *ProxyHandler) serveLocalGemsIndex(w http.ResponseWriter, r *http.Request, remote *models.Remote, path string) {
body, err := h.LocalGemsIndex(r.Context(), remote.Name, path)
if errors.Is(err, virtual.ErrNotFound) {
gemsText(w, http.StatusNotFound, "not found")
return
}
if err != nil {
gemsText(w, http.StatusInternalServerError, err.Error())
return
}
w.Header().Set("X-Artifact-Source", "local")
gemsText(w, http.StatusOK, string(body))
}
// LocalGemsIndex builds a local gems repo's versions, names or info/<gem>
// document, or returns virtual.ErrNotFound.
func (h *ProxyHandler) LocalGemsIndex(ctx context.Context, repo, path string) ([]byte, error) {
if name, ok := strings.CutPrefix(path, "info/"); ok {
if !gems.NameRe.MatchString(name) {
return nil, virtual.ErrNotFound
}
lines, err := h.localGemsLines(ctx, repo, gemsIndexPrefix+name+"/")
if err != nil {
return nil, err
}
if len(lines[name]) == 0 {
return nil, virtual.ErrNotFound
}
return gems.InfoBody(lines[name]), nil
}
if path != "versions" && path != "names" {
return nil, virtual.ErrNotFound
}
lines, err := h.localGemsLines(ctx, repo, gemsIndexPrefix)
if err != nil {
return nil, err
}
names := slices.Sorted(maps.Keys(lines))
var b strings.Builder
if path == "names" {
b.WriteString("---\n")
for _, n := range names {
b.WriteString(n + "\n")
}
return []byte(b.String()), nil
}
b.WriteString(gems.VersionsHeader(time.Now()))
for _, n := range names {
b.WriteString(gems.VersionsLine(n, gems.InfoBody(lines[n])) + "\n")
}
return []byte(b.String()), nil
}
// localGemsLines returns /info lines per gem name in push order.
func (h *ProxyHandler) localGemsLines(ctx context.Context, repo, prefix string) (map[string][]string, error) {
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(a.ID, b.ID) })
out := map[string][]string{}
for _, f := range files {
// LIKE treats '_' as a wildcard, so the prefix match can over-select.
rest, ok := strings.CutPrefix(f.FilePath, prefix)
if !ok {
continue
}
name := strings.TrimPrefix(prefix, gemsIndexPrefix)
if name == "" {
name, _, _ = strings.Cut(rest, "/")
} else {
name = strings.TrimSuffix(name, "/")
}
line, err := h.readLocalBlob(ctx, f.ContentHash)
if err != nil {
return nil, err
}
out[name] = append(out[name], string(line))
}
return out, nil
}
+254
View File
@@ -0,0 +1,254 @@
package v1
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"crypto/md5"
"crypto/sha256"
"encoding/hex"
"fmt"
"net/http"
"strings"
"sync"
"testing"
"time"
"git.unkin.net/unkin/artifactapi/internal/cache"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
func testGemFile(t *testing.T, name, version, platform string) []byte {
t.Helper()
meta := fmt.Sprintf(`--- !ruby/object:Gem::Specification
name: %s
version: !ruby/object:Gem::Version
version: %s
platform: %s
dependencies:
- !ruby/object:Gem::Dependency
name: rack
requirement: !ruby/object:Gem::Requirement
requirements:
- - "~>"
- !ruby/object:Gem::Version
version: '3.0'
type: :runtime
prerelease: false
rubygems_version: 3.5.22
specification_version: 4
summary: test
`, name, version, platform)
var gz bytes.Buffer
zw := gzip.NewWriter(&gz)
_, _ = zw.Write([]byte(meta))
_ = zw.Close()
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
_ = tw.WriteHeader(&tar.Header{Name: "metadata.gz", Mode: 0o444, Size: int64(gz.Len())})
_, _ = tw.Write(gz.Bytes())
_ = tw.Close()
return buf.Bytes()
}
func sha256Hex(b []byte) string {
s := sha256.Sum256(b)
return hex.EncodeToString(s[:])
}
func TestGemsPushIndexYank(t *testing.T) {
h, srv := npmTestServer(t)
repo := npmRepo(t, h, models.PackageGems)
base := srv.URL + "/api/v1/local/" + repo + "/"
g1 := testGemFile(t, "hello_gem", "0.1.0", "ruby")
g2 := testGemFile(t, "hello_gem", "0.2.0", "ruby")
gj := testGemFile(t, "hello_gem", "0.2.0", "java")
other := testGemFile(t, "hello-gem", "1.0.0", "ruby")
for _, g := range [][]byte{g1, g2, gj, other} {
if code, body := npmDo(t, http.MethodPost, base+"api/v1/gems", string(g)); code != http.StatusOK {
t.Fatalf("push = %d: %s", code, body)
}
}
if code, _ := npmDo(t, http.MethodPost, base+"api/v1/gems", string(g1)+"x"); code != http.StatusConflict {
t.Fatalf("repush with different bytes = %d, want 409", code)
}
if code, _ := npmDo(t, http.MethodPost, base+"api/v1/gems", string(g1)); code != http.StatusConflict {
t.Fatalf("identical repush = %d, want 409", code)
}
if code, _ := npmDo(t, http.MethodPost, base+"api/v1/gems", "not a gem"); code != http.StatusBadRequest {
t.Fatalf("garbage push = %d, want 400", code)
}
code, info := npmDo(t, http.MethodGet, base+"info/hello_gem", "")
want := "---\n0.1.0 rack:~> 3.0|checksum:" + sha256Hex(g1) + "\n0.2.0 rack:~> 3.0|checksum:" + sha256Hex(g2) +
"\n0.2.0-java rack:~> 3.0|checksum:" + sha256Hex(gj) + "\n"
if code != http.StatusOK || info != want {
t.Fatalf("info = %d\n%q\nwant\n%q", code, info, want)
}
if code, _ := npmDo(t, http.MethodHead, base+"versions", ""); code != http.StatusOK {
t.Fatalf("HEAD versions = %d; gem probes the compact index with HEAD", code)
}
_, versions := npmDo(t, http.MethodGet, base+"versions", "")
head, body, ok := strings.Cut(versions, "\n---\n")
if !ok || !strings.HasPrefix(head, "created_at: ") {
t.Fatalf("versions header = %q", versions)
}
_, otherInfo := npmDo(t, http.MethodGet, base+"info/hello-gem", "")
wantBody := fmt.Sprintf("hello-gem 1.0.0 %x\nhello_gem 0.1.0,0.2.0,0.2.0-java %x\n", md5.Sum([]byte(otherInfo)), md5.Sum([]byte(info)))
if body != wantBody {
t.Fatalf("versions body =\n%q\nwant\n%q", body, wantBody)
}
if _, names := npmDo(t, http.MethodGet, base+"names", ""); names != "---\nhello-gem\nhello_gem\n" {
t.Fatalf("names = %q", names)
}
if code, got := npmDo(t, http.MethodGet, base+"gems/hello_gem-0.2.0-java.gem", ""); code != http.StatusOK || got != string(gj) {
t.Fatalf("download = %d", code)
}
if code, _ := npmDo(t, http.MethodGet, base+"quick/Marshal.4.8/hello_gem-0.1.0.gemspec.rz", ""); code != http.StatusOK {
t.Fatalf("quick spec = %d", code)
}
if code, _ := npmDo(t, http.MethodGet, base+"info/missing", ""); code != http.StatusNotFound {
t.Fatalf("missing info = %d", code)
}
yank := func(form string) int {
req, _ := http.NewRequest(http.MethodDelete, base+"api/v1/gems/yank", strings.NewReader(form))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
_ = resp.Body.Close()
return resp.StatusCode
}
if c := yank("gem_name=hello_gem&version=0.2.0&platform=java"); c != http.StatusOK {
t.Fatalf("yank = %d", c)
}
if c := yank("gem_name=hello_gem&version=0.2.0&platform=java"); c != http.StatusNotFound {
t.Fatalf("second yank = %d, want 404", c)
}
if c := yank("gem_name=../x&version=1"); c != http.StatusBadRequest {
t.Fatalf("bad yank = %d, want 400", c)
}
if _, info := npmDo(t, http.MethodGet, base+"info/hello_gem", ""); strings.Contains(info, "java") || !strings.Contains(info, "0.2.0 ") {
t.Fatalf("info after yank = %q", info)
}
if code, _ := npmDo(t, http.MethodGet, base+"gems/hello_gem-0.2.0-java.gem", ""); code != http.StatusNotFound {
t.Fatalf("yanked download = %d, want 404", code)
}
if _, v := npmDo(t, http.MethodGet, base+"versions", ""); strings.Contains(v, "java") || !strings.Contains(v, "hello_gem 0.1.0,0.2.0 ") {
t.Fatalf("versions after yank = %q", v)
}
for _, g := range []string{string(gj), string(gj) + "x"} {
if code, body := npmDo(t, http.MethodPost, base+"api/v1/gems", g); code != http.StatusConflict || !strings.Contains(body, "yanked") {
t.Fatalf("push after yank = %d %q, want 409", code, body)
}
}
if _, info := npmDo(t, http.MethodGet, base+"info/hello_gem", ""); strings.Contains(info, "java") {
t.Fatalf("info after rejected re-push = %q", info)
}
if c := yank("gem_name=hello-gem&version=1.0.0"); c != http.StatusOK {
t.Fatalf("yank last version = %d", c)
}
if code, _ := npmDo(t, http.MethodGet, base+"info/hello-gem", ""); code != http.StatusNotFound {
t.Fatalf("info for fully yanked gem = %d, want 404", code)
}
if _, names := npmDo(t, http.MethodGet, base+"names", ""); names != "---\nhello_gem\n" {
t.Fatalf("names after yanking every version = %q", names)
}
}
func TestGemsPushRejects(t *testing.T) {
h, srv := npmTestServer(t)
npmLocal := npmRepo(t, h, models.PackageNPM)
g := string(testGemFile(t, "g", "1.0.0", "ruby"))
if code, _ := npmDo(t, http.MethodPost, srv.URL+"/api/v1/local/"+npmLocal+"/api/v1/gems", g); code != http.StatusMethodNotAllowed {
t.Fatalf("push to npm local = %d, want 405", code)
}
repo := npmRepo(t, h, models.PackageGems)
if code, _ := npmDo(t, http.MethodPost, srv.URL+"/api/v1/local/"+repo+"/other", g); code != http.StatusNotFound {
t.Fatalf("push to other path = %d, want 404", code)
}
old := gemsMaxPushBytes
gemsMaxPushBytes = 64
defer func() { gemsMaxPushBytes = old }()
if code, _ := npmDo(t, http.MethodPost, srv.URL+"/api/v1/local/"+repo+"/api/v1/gems", g); code != http.StatusRequestEntityTooLarge {
t.Fatalf("oversized push = %d, want 413", code)
}
}
func TestGemsPushYankSerialised(t *testing.T) {
h, srv := npmTestServer(t)
repo := npmRepo(t, h, models.PackageGems)
base := srv.URL + "/api/v1/local/" + repo + "/"
g := string(testGemFile(t, "race", "1.0.0", "ruby"))
var wg sync.WaitGroup
for i := 0; i < 16; i++ {
wg.Add(2)
go func() {
defer wg.Done()
if code, body := npmDo(t, http.MethodPost, base+"api/v1/gems", g); code != http.StatusOK && code != http.StatusConflict {
t.Errorf("push = %d: %s", code, body)
}
}()
go func() {
defer wg.Done()
if code, body := npmDo(t, http.MethodDelete, base+"api/v1/gems/yank?gem_name=race&version=1.0.0", ""); code != http.StatusOK && code != http.StatusNotFound {
t.Errorf("yank = %d: %s", code, body)
}
}()
}
wg.Wait()
present := func(p string) bool {
f, err := h.db.GetLocalFile(context.Background(), repo, p)
if err != nil {
t.Fatal(err)
}
return f != nil
}
live := []string{"_index/race/race-1.0.0", "gems/race-1.0.0.gem", "quick/Marshal.4.8/race-1.0.0.gemspec.rz"}
tomb := present("_yanked/race/race-1.0.0")
for _, p := range live {
if present(p) == tomb {
t.Errorf("tombstoned=%v but %s present=%v", tomb, p, !tomb)
}
}
_, info := npmDo(t, http.MethodGet, base+"info/race", "")
if strings.Contains(info, "1.0.0") == tomb {
t.Errorf("tombstoned=%v but info = %q", tomb, info)
}
}
func TestGemsLockTimeoutIs503(t *testing.T) {
h, srv := npmTestServer(t)
repo := npmRepo(t, h, models.PackageGems)
base := srv.URL + "/api/v1/local/" + repo + "/"
redis, err := cache.NewRedis(testRedis)
if err != nil {
t.Fatal(err)
}
token, err := redis.AcquireLock(context.Background(), repo, "_gemlock/busy-1.0.0", time.Minute)
if token == "" || err != nil {
t.Fatalf("hold lock = %q %v", token, err)
}
prev := gemsLockWait
gemsLockWait = 100 * time.Millisecond
t.Cleanup(func() { gemsLockWait = prev })
if code, _ := npmDo(t, http.MethodPost, base+"api/v1/gems", string(testGemFile(t, "busy", "1.0.0", "ruby"))); code != http.StatusServiceUnavailable {
t.Fatalf("push under held lock = %d, want 503", code)
}
if code, _ := npmDo(t, http.MethodDelete, base+"api/v1/gems/yank?gem_name=busy&version=1.0.0", ""); code != http.StatusServiceUnavailable {
t.Fatalf("yank under held lock = %d, want 503", code)
}
if err := redis.ReleaseLock(context.Background(), repo, "_gemlock/busy-1.0.0", token); err != nil {
t.Fatal(err)
}
if code, body := npmDo(t, http.MethodPost, base+"api/v1/gems", string(testGemFile(t, "busy", "1.0.0", "ruby"))); code != http.StatusOK {
t.Fatalf("push after release = %d: %s", code, body)
}
}
+39
View File
@@ -0,0 +1,39 @@
package v1
import (
"errors"
"log/slog"
"net/http"
"git.unkin.net/unkin/artifactapi/internal/virtual"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
// serveVirtualGems serves the merged compact index and 302s downloads to the
// member owning that version.
func (h *ProxyHandler) serveVirtualGems(w http.ResponseWriter, r *http.Request, virt models.Virtual, path, proxyBaseURL string) {
var body []byte
var loc string
var err error
if isGemsIndexPath(path) {
body, _, err = h.virtualEngine.FetchGems(r.Context(), virt, path)
} else {
loc, err = h.virtualEngine.GemsRedirect(r.Context(), virt, path, r.URL.RawQuery, proxyBaseURL)
}
switch {
case errors.Is(err, virtual.ErrNotFound):
gemsText(w, http.StatusNotFound, "not found")
case errors.Is(err, virtual.ErrBadPath):
gemsText(w, http.StatusBadRequest, err.Error())
case err != nil:
slog.Error("virtual gems fetch failed", "virtual", virt.Name, "path", path, "error", err)
gemsText(w, http.StatusBadGateway, "bad gateway")
case loc != "":
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Vary", "Host, X-Forwarded-Proto")
http.Redirect(w, r, loc, http.StatusFound)
default:
w.Header().Set("X-Artifact-Source", "virtual")
gemsText(w, http.StatusOK, string(body))
}
}
+33
View File
@@ -0,0 +1,33 @@
package v1
import (
"errors"
"log/slog"
"net/http"
"git.unkin.net/unkin/artifactapi/internal/virtual"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
// serveVirtualGoProxy serves the merged @v/list and 302s @latest and version
// files to the member owning that version.
func (h *ProxyHandler) serveVirtualGoProxy(w http.ResponseWriter, r *http.Request, virt models.Virtual, path string) {
body, loc, err := h.virtualEngine.GoProxy(r.Context(), virt, path, r.URL.RawQuery)
switch {
case errors.Is(err, virtual.ErrNotFound):
http.Error(w, "not found", http.StatusNotFound)
case errors.Is(err, virtual.ErrBadPath):
http.Error(w, err.Error(), http.StatusBadRequest)
case err != nil:
slog.Error("virtual goproxy fetch failed", "virtual", virt.Name, "path", path, "error", err)
http.Error(w, "bad gateway", http.StatusBadGateway)
case loc != "":
w.Header().Set("Cache-Control", "no-store")
http.Redirect(w, r, loc, http.StatusFound)
default:
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("X-Artifact-Source", "virtual")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(body)
}
}
+16 -2
View File
@@ -15,7 +15,9 @@ import (
"github.com/go-chi/chi/v5"
"git.unkin.net/unkin/artifactapi/internal/cache"
"git.unkin.net/unkin/artifactapi/internal/database"
"git.unkin.net/unkin/artifactapi/internal/proxy"
"git.unkin.net/unkin/artifactapi/internal/storage"
"git.unkin.net/unkin/artifactapi/internal/testsupport"
"git.unkin.net/unkin/artifactapi/internal/virtual"
@@ -25,6 +27,7 @@ import (
var (
testDSN string
testMinio testsupport.MinioConn
testRedis string
)
func TestMain(m *testing.M) {
@@ -38,8 +41,15 @@ func TestMain(m *testing.M) {
termPG()
os.Exit(m.Run())
}
testDSN, testMinio = dsn, conn
redisURL, termRedis, err := testsupport.StartRedis(ctx)
if err != nil {
termMinio()
termPG()
os.Exit(m.Run())
}
testDSN, testMinio, testRedis = dsn, conn, redisURL
code := m.Run()
termRedis()
termMinio()
termPG()
os.Exit(code)
@@ -65,7 +75,11 @@ func npmTestServer(t *testing.T) (*ProxyHandler, *httptest.Server) {
if err != nil {
t.Fatal(err)
}
h := NewProxyHandler(nil, nil, db, store, nil)
redis, err := cache.NewRedis(testRedis)
if err != nil {
t.Fatal(err)
}
h := NewProxyHandler(proxy.NewEngine(db, redis, store), nil, db, store, nil)
r := chi.NewRouter()
r.Mount("/api/v1", h.Routes())
srv := httptest.NewServer(r)
+86 -3
View File
@@ -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"
@@ -47,8 +51,12 @@ func (h *ProxyHandler) Routes() chi.Router {
r.Get("/remote/{remoteName}/*", h.handleProxy)
r.Head("/remote/{remoteName}/*", h.handleProxyHead)
r.Get("/local/{localName}/*", h.handleLocal)
r.Head("/local/{localName}/*", h.handleLocal)
r.Put("/local/{localName}/*", h.handleLocalPut)
r.Post("/local/{localName}/*", h.handleLocalPost)
r.Delete("/local/{localName}/*", h.handleLocalDelete)
r.Get("/virtual/{virtualName}/*", h.handleVirtual)
r.Head("/virtual/{virtualName}/*", h.handleVirtual)
return r
}
@@ -111,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)
@@ -143,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)
@@ -170,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)
@@ -201,6 +227,23 @@ func (h *ProxyHandler) handleVirtual(w http.ResponseWriter, r *http.Request) {
return
}
if virt.PackageType == models.PackageGems {
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 {
http.Error(w, err.Error(), http.StatusBadRequest)
@@ -254,6 +297,27 @@ func (h *ProxyHandler) handleLocal(w http.ResponseWriter, r *http.Request) {
return
}
}
if remote.PackageType == models.PackageGems {
if isGemsIndexPath(path) {
h.serveLocalGemsIndex(w, r, remote, path)
return
}
if strings.HasPrefix(path, gemsIndexPrefix) || strings.HasPrefix(path, gemsYankedPrefix) {
http.Error(w, "not found", http.StatusNotFound)
return
}
}
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 {
@@ -306,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) {
+16
View File
@@ -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"))
}
}
+77
View File
@@ -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}})
}
+90
View File
@@ -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)
}
}
}
+38 -1
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"io"
"net/http"
"os"
"github.com/go-chi/chi/v5"
@@ -83,7 +84,22 @@ func (h *LocalHandler) uploadValidated(w http.ResponseWriter, r *http.Request, r
return
}
result, err := h.cas.Store(r.Context(), r.Body, contentType)
var body io.Reader = r.Body
if cv, ok := prov.(provider.ContentValidator); ok {
f, err := spoolBody(r.Body)
if err != nil {
http.Error(w, fmt.Sprintf("read upload: %v", err), http.StatusInternalServerError)
return
}
defer discard(f)
if err := cv.ValidateContent(storagePath, f.Name()); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
body = f
}
result, err := h.cas.Store(r.Context(), body, contentType)
if err != nil {
http.Error(w, fmt.Sprintf("store failed: %v", err), http.StatusInternalServerError)
return
@@ -110,6 +126,27 @@ func (h *LocalHandler) uploadValidated(w http.ResponseWriter, r *http.Request, r
writeJSON(w, http.StatusCreated, uploader.UploadResponse(storagePath, result.ContentHash, result.SizeBytes))
}
func spoolBody(r io.Reader) (*os.File, error) {
f, err := os.CreateTemp("", "artifactapi-upload-*")
if err != nil {
return nil, err
}
if _, err := io.Copy(f, r); err != nil {
discard(f)
return nil, err
}
if _, err := f.Seek(0, io.SeekStart); err != nil {
discard(f)
return nil, err
}
return f, nil
}
func discard(f *os.File) {
_ = f.Close()
_ = os.Remove(f.Name())
}
func (h *LocalHandler) uploadGeneric(w http.ResponseWriter, r *http.Request, remote *models.Remote, filePath string) {
existing, err := h.db.GetLocalFile(r.Context(), remote.Name, filePath)
if err != nil {
+55
View File
@@ -0,0 +1,55 @@
package v2
import (
"context"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"git.unkin.net/unkin/artifactapi/internal/database"
_ "git.unkin.net/unkin/artifactapi/internal/provider/goproxy"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
// TestLocalUploadContentRejected verifies a ContentValidator provider rejects
// bad bytes with 400 before anything is stored.
func TestLocalUploadContentRejected(t *testing.T) {
if testDSN == "" {
t.Skip("Docker unavailable")
}
ctx := context.Background()
db, err := database.New(testDSN)
if err != nil {
t.Fatal(err)
}
defer db.Close()
const repo = "go-local-validate"
if err := db.CreateRemote(ctx, &models.Remote{Name: repo, PackageType: models.PackageGoProxy, RepoType: models.RepoTypeLocal}); err != nil {
t.Fatal(err)
}
router := chi.NewRouter()
router.Route("/remotes/{name}/files", func(r chi.Router) {
r.Put("/*", NewLocalHandler(db, nil).Routes().ServeHTTP)
})
cases := map[string]string{
"example.unkin.net/hello/@v/v0.1.0.zip": "not a zip",
"example.unkin.net/hello/@v/v0.1.0.mod": "module example.unkin.net/other\n",
"example.unkin.net/hello/@v/v0.1.0.info": `{"Version":"v9.9.9"}`,
"example.unkin.net/Hello/@v/v0.1.0.mod": "module example.unkin.net/Hello\n",
}
for path, body := range cases {
w := httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest("PUT", "/remotes/"+repo+"/files/"+path, strings.NewReader(body)))
if w.Code != 400 {
t.Errorf("%s: status %d, want 400 (%s)", path, w.Code, w.Body.String())
}
if f, _ := db.GetLocalFile(ctx, repo, path); f != nil {
t.Errorf("%s: rejected upload was recorded", path)
}
}
}
+18 -1
View File
@@ -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)
}
}
+109
View File
@@ -55,3 +55,112 @@ 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 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-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)
}
}
}
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)
}
}
}
+56
View File
@@ -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
View File
@@ -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 + `"}`))
}
+88
View File
@@ -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)
}
}
+43 -7
View File
@@ -61,18 +61,54 @@ func TestTTL(t *testing.T) {
func TestLock(t *testing.T) {
requireRedis(t)
ctx := context.Background()
ok, err := testRedis.AcquireLock(ctx, "r", "lockpath", time.Minute)
if err != nil || !ok {
t.Fatalf("first acquire should succeed: %v %v", ok, err)
token, err := testRedis.AcquireLock(ctx, "r", "lockpath", time.Minute)
if err != nil || token == "" {
t.Fatalf("first acquire should succeed: %q %v", token, err)
}
if ok, _ := testRedis.AcquireLock(ctx, "r", "lockpath", time.Minute); ok {
if again, _ := testRedis.AcquireLock(ctx, "r", "lockpath", time.Minute); again != "" {
t.Error("second acquire should fail while held")
}
if err := testRedis.ReleaseLock(ctx, "r", "lockpath"); err != nil {
if err := testRedis.ReleaseLock(ctx, "r", "lockpath", "not-the-owner"); err != nil {
t.Fatal(err)
}
if ok, _ := testRedis.AcquireLock(ctx, "r", "lockpath", time.Minute); !ok {
t.Error("acquire should succeed after release")
if again, _ := testRedis.AcquireLock(ctx, "r", "lockpath", time.Minute); again != "" {
t.Error("release with a foreign token must not drop the lock")
}
if err := testRedis.ReleaseLock(ctx, "r", "lockpath", token); err != nil {
t.Fatal(err)
}
next, _ := testRedis.AcquireLock(ctx, "r", "lockpath", time.Minute)
if next == "" {
t.Fatal("acquire should succeed after release")
}
if next == token {
t.Error("tokens must differ between holders")
}
}
func TestStaleReleaseKeepsNextHolder(t *testing.T) {
requireRedis(t)
ctx := context.Background()
stale, err := testRedis.AcquireLock(ctx, "r", "stale", 50*time.Millisecond)
if err != nil || stale == "" {
t.Fatalf("acquire: %q %v", stale, err)
}
time.Sleep(100 * time.Millisecond)
fresh, err := testRedis.AcquireLock(ctx, "r", "stale", time.Minute)
if err != nil || fresh == "" {
t.Fatalf("acquire after expiry: %q %v", fresh, err)
}
if err := testRedis.ReleaseLock(ctx, "r", "stale", stale); err != nil {
t.Fatal(err)
}
if again, _ := testRedis.AcquireLock(ctx, "r", "stale", time.Minute); again != "" {
t.Fatal("stale holder's release dropped the second holder's lock")
}
if err := testRedis.ReleaseLock(ctx, "r", "stale", fresh); err != nil {
t.Fatal(err)
}
if again, _ := testRedis.AcquireLock(ctx, "r", "stale", time.Minute); again == "" {
t.Error("owner release should free the lock")
}
}
+28 -5
View File
@@ -2,6 +2,7 @@ package cache
import (
"context"
"crypto/rand"
"fmt"
"strings"
"time"
@@ -46,15 +47,23 @@ func (r *Redis) CheckTTL(ctx context.Context, remote, path string) (bool, error)
return exists > 0, nil
}
func (r *Redis) AcquireLock(ctx context.Context, remote, path string, ttl time.Duration) (bool, error) {
// AcquireLock returns the owner token on success and "" when the lock is held.
func (r *Redis) AcquireLock(ctx context.Context, remote, path string, ttl time.Duration) (string, error) {
key := fmt.Sprintf("lock:%s:%s", remote, path)
ok, err := r.client.SetNX(ctx, key, "1", ttl).Result()
return ok, err
token := rand.Text()
ok, err := r.client.SetNX(ctx, key, token, ttl).Result()
if err != nil || !ok {
return "", err
}
return token, nil
}
func (r *Redis) ReleaseLock(ctx context.Context, remote, path string) error {
var releaseScript = redis.NewScript(`if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end`)
// ReleaseLock deletes the lock only while token still owns it.
func (r *Redis) ReleaseLock(ctx context.Context, remote, path, token string) error {
key := fmt.Sprintf("lock:%s:%s", remote, path)
return r.client.Del(ctx, key).Err()
return releaseScript.Run(ctx, r.client, []string{key}, token).Err()
}
// SetETag stores etag for path, or drops the stored one when etag is empty.
@@ -75,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 {
+10
View File
@@ -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
+20
View File
@@ -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")
}
}
+2 -2
View File
@@ -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
+111 -2
View File
@@ -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)
}
}
+2 -2
View File
@@ -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
+4 -4
View File
@@ -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)
}
+17 -9
View File
@@ -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,30 @@ 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 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+`)
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 {
+198 -5
View File
@@ -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)
}
@@ -207,3 +211,192 @@ func TestGitHubSyncLeaseFailureBackoff(t *testing.T) {
t.Fatal("claimed inside freshness window after a successful sync")
}
}
// Deleting a remote drops its sync state, so a recreated remote of the same name
// starts clean instead of inheriting the old ETag, sync time and retry state.
func TestGitHubSyncStateDeletedWithRemote(t *testing.T) {
requireDB(t)
for _, tc := range []struct {
table string
pt models.PackageType
claim func(name string) (bool, string, error)
release func(name string, res provider.SyncResult) error
}{
{"github_rpm_sync_state", models.PackageGitHubRPM,
func(n string) (bool, string, error) {
return testDB.ClaimGitHubSyncLease(ctx(), n, "r1", time.Hour, time.Minute, 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, 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, 0, 0)
},
func(n string, res provider.SyncResult) error {
return testDB.ReleaseGitHubAlpineSyncLease(ctx(), n, "r1", res)
}},
} {
t.Run(tc.table, func(t *testing.T) {
name := "gh-recreate-" + time.Now().Format("150405.000000")
create := func() {
t.Helper()
if err := testDB.CreateRemote(ctx(), &models.Remote{
Name: name, PackageType: tc.pt, RepoType: models.RepoTypeRemote,
BaseURL: "https://api.github.com/repos/acme/tools", ReleasesRemote: "github", MutableTTL: 3600,
}); err != nil {
t.Fatalf("create remote: %v", err)
}
}
create()
if ok, _, err := tc.claim(name); err != nil || !ok {
t.Fatalf("claim: ok=%v err=%v", ok, err)
}
if err := tc.release(name, provider.SyncResult{Etag: `"old"`}); err != nil {
t.Fatalf("release: %v", err)
}
if err := testDB.DeleteRemote(ctx(), name); err != nil {
t.Fatalf("delete remote: %v", err)
}
var rows int
if err := testDB.Pool.QueryRow(ctx(), `SELECT count(*) FROM `+tc.table+` WHERE remote_name = $1`, name).Scan(&rows); err != nil || rows != 0 {
t.Fatalf("sync state left after delete: rows=%d err=%v", rows, err)
}
create()
ok, etag, err := tc.claim(name)
if err != nil || !ok || etag != "" {
t.Fatalf("recreated remote claim ok=%v etag=%q err=%v, want a fresh claim with no etag", ok, etag, err)
}
})
}
}
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)
}
}
})
}
}
+22 -9
View File
@@ -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
})
}
+7
View File
@@ -40,6 +40,13 @@ 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.
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
+9 -9
View File
@@ -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
}
+3
View File
@@ -0,0 +1,3 @@
package all
import _ "git.unkin.net/unkin/artifactapi/internal/provider/ansible"
+3
View File
@@ -0,0 +1,3 @@
package all
import _ "git.unkin.net/unkin/artifactapi/internal/provider/vcs"
+14 -13
View File
@@ -372,6 +372,9 @@ func (p *GitHubProvider) scanWithState(ctx context.Context, remote models.Remote
return newEtag, false, err
}
// A transiently failed asset fails the scan so the ETag is not advanced and
// the retry re-derives it; an invalid package is skipped as no retry fixes it.
var failed []error
seen := map[string]bool{}
for _, rel := range releases {
if rel.Draft {
@@ -400,10 +403,14 @@ func (p *GitHubProvider) scanWithState(ctx context.Context, remote models.Remote
meta, err := p.deriveAsset(ctx, remote, asset, fp)
if err != nil {
slog.Warn("github_alpine: derive asset failed", "remote", remote.Name, "asset", asset.Name, "error", err)
if !errors.Is(err, provider.ErrInvalidPackage) {
failed = append(failed, fmt.Errorf("%s: %w", asset.Name, err))
}
continue
}
if err := inserter.InsertAlpineMetadata(ctx, meta); err != nil {
slog.Error("github_alpine: insert metadata failed", "remote", remote.Name, "asset", asset.Name, "error", err)
failed = append(failed, fmt.Errorf("%s: %w", asset.Name, err))
continue
}
slog.Info("github_alpine: derived asset", "remote", remote.Name, "name", meta.Name, "version", meta.Version, "arch", meta.Arch)
@@ -415,7 +422,7 @@ func (p *GitHubProvider) scanWithState(ctx context.Context, remote models.Remote
_ = deleter.DeleteAlpineMetadata(ctx, remote.Name, fp)
}
}
return newEtag, true, nil
return newEtag, true, errors.Join(failed...)
}
type ghRelease struct {
@@ -496,7 +503,7 @@ func (p *GitHubProvider) deriveAsset(ctx context.Context, remote models.Remote,
return nil, err
}
if meta.Name == "" || meta.Arch == "" {
return nil, errors.New(".PKGINFO missing pkgname/arch")
return nil, fmt.Errorf("%w: .PKGINFO missing pkgname/arch", provider.ErrInvalidPackage)
}
meta.RepoName = remote.Name
@@ -526,13 +533,13 @@ func (p *GitHubProvider) fetchPkginfo(ctx context.Context, remote models.Remote,
}
meta, complete, perr := pkginfoFromPrefix(body)
if perr != nil {
return nil, fmt.Errorf("parse apk .PKGINFO: %w", perr)
return nil, fmt.Errorf("%w: parse apk .PKGINFO: %w", provider.ErrInvalidPackage, perr)
}
if complete {
return meta, nil
}
if full || n >= p.headerMax {
return nil, fmt.Errorf(".PKGINFO not found within %d bytes of %s", n, downloadURL)
return nil, fmt.Errorf("%w: .PKGINFO not found within %d bytes of %s", provider.ErrInvalidPackage, n, downloadURL)
}
n *= 2
if n > p.headerMax {
@@ -594,7 +601,7 @@ func pkginfoFromPrefix(prefix []byte) (meta *provider.AlpineMetadata, complete b
}
// rangeGet returns the first n bytes of downloadURL. full is true when the
// response body was shorter than n (i.e. we already have the whole object).
// response holds the whole object.
func (p *GitHubProvider) rangeGet(ctx context.Context, remote models.Remote, downloadURL string, n int64) ([]byte, bool, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
if err != nil {
@@ -616,15 +623,9 @@ func (p *GitHubProvider) rangeGet(ctx context.Context, remote models.Remote, dow
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {
return nil, false, fmt.Errorf("range GET %s: status %d", downloadURL, resp.StatusCode)
return nil, false, fmt.Errorf("range GET: %w", provider.AssetStatusError(downloadURL, resp))
}
body, err := io.ReadAll(io.LimitReader(resp.Body, n))
if err != nil {
return nil, false, err
}
full := int64(len(body)) < n
return body, full, nil
return provider.ReadPrefix(resp, n)
}
// assetPath is the package's location relative to github.com — the path the
+22 -3
View File
@@ -8,6 +8,7 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -88,15 +89,17 @@ type githubFixture struct {
notModHit int
releaseAuth string
assetAuth string
assetFail map[string]int // asset filename -> downloads left to answer 500
mu sync.Mutex
}
func newGitHubFixture(t *testing.T, withDigest bool) *githubFixture {
t.Helper()
f := &githubFixture{
apkBytes: map[string][]byte{},
rangeHit: map[string]int{},
fullHit: map[string]int{},
apkBytes: map[string][]byte{},
rangeHit: map[string]int{},
fullHit: map[string]int{},
assetFail: map[string]int{},
}
f.apkBytes["demo-1.2.3-r0.apk"] = testsupport.MinimalApk("demo", "1.2.3-r0", "x86_64")
@@ -146,6 +149,12 @@ func newGitHubFixture(t *testing.T, withDigest bool) *githubFixture {
}
rng := r.Header.Get("Range")
f.mu.Lock()
if f.assetFail[name] > 0 {
f.assetFail[name]--
f.mu.Unlock()
http.Error(w, "boom", http.StatusInternalServerError)
return
}
f.assetAuth = r.Header.Get("Authorization")
if rng != "" {
f.rangeHit[name]++
@@ -495,3 +504,13 @@ func readAPKIndex(t *testing.T, gzBytes []byte) string {
}
}
}
func TestGitHubDeriveAssetMissingPkgnameIsInvalid(t *testing.T) {
fx := newGitHubFixture(t, true)
fx.apkBytes["nameless-1-r0.apk"] = testsupport.MinimalApk("", "1-r0", "x86_64")
asset := ghAsset{BrowserDownloadURL: fx.srv.URL + "/acme/tools/releases/download/v1.2.3/nameless-1-r0.apk"}
_, err := newTestProvider().deriveAsset(context.Background(), fx.remote(), asset, "fp")
if !errors.Is(err, provider.ErrInvalidPackage) {
t.Fatalf("want ErrInvalidPackage, got %v", err)
}
}
+11 -8
View File
@@ -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
+97 -2
View File
@@ -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)
}
@@ -308,3 +314,92 @@ func TestSyncerPrimeBypassesRecencyPeriodicDoesNot(t *testing.T) {
t.Fatalf("periodic scan ran inside recency window: %d -> %d releases calls", releasesAfterPrime, fx.releasesHit)
}
}
// A transiently failed asset fails the scan: the ETag is not advanced, a retry
// is scheduled, and the retry against the unchanged release list derives it. An
// invalid package is skipped instead, since no retry can fix it.
func TestSyncerFailedAssetRetry(t *testing.T) {
for _, tc := range []struct {
name string
corrupt bool
wantRetry bool
}{
{name: "transient", wantRetry: true},
{name: "invalid package", corrupt: true},
} {
t.Run(tc.name, func(t *testing.T) {
fx := newGitHubFixture(t, true)
fx.etag = `"v1"`
if tc.corrupt {
fx.apkBytes["other-9-r0.apk"] = []byte("not a package")
} else {
fx.apkBytes["other-9-r0.apk"] = testsupport.MinimalApk("other", "9-r0", "aarch64")
fx.assetFail["other-9-r0.apk"] = 1
}
store := newFakeSyncStore()
s := newSyncer(store, newTestProvider(), testSyncConfig())
remote := fx.remote()
bg := context.Background()
s.process(bg, syncJob{remote: remote, prime: true})
_, pending := store.retryAt[remote.Name]
if pending != tc.wantRetry {
t.Fatalf("retry pending=%v, want %v", pending, tc.wantRetry)
}
if _, saved := store.etags[remote.Name]; saved == tc.wantRetry {
t.Fatalf("etag saved=%v after first scan", saved)
}
if rows, _ := store.ListAlpineMetadataEntries(bg, remote.Name); len(rows) != 1 {
t.Fatalf("healthy asset not served: %d rows", len(rows))
}
if !tc.wantRetry {
return
}
store.mu.Lock()
store.retryAt[remote.Name] = time.Now().Add(-time.Second)
store.mu.Unlock()
s.process(bg, syncJob{remote: remote})
if _, pending := store.retryAt[remote.Name]; pending || store.etags[remote.Name] != `"v1"` {
t.Fatalf("retry not recorded: pending=%v etag=%q", pending, store.etags[remote.Name])
}
if rows, _ := store.ListAlpineMetadataEntries(bg, remote.Name); len(rows) != 2 {
t.Fatalf("retry did not derive the failed asset: %d rows", len(rows))
}
})
}
}
// 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)
}
}
+139
View File
@@ -0,0 +1,139 @@
// Package ansible proxies an Ansible Galaxy server (v3 collections API):
// mutable JSON metadata whose links are rewritten back to the remote, plus
// immutable collection tarballs.
package ansible
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/url"
"strconv"
"strings"
"git.unkin.net/unkin/artifactapi/internal/auth"
"git.unkin.net/unkin/artifactapi/internal/provider"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
func init() {
provider.Register(&Provider{})
}
type Provider struct{}
func (p *Provider) Type() models.PackageType { return models.PackageAnsible }
func (p *Provider) Classify(path string) provider.Mutability {
if isArtifact(path) {
return provider.Immutable
}
return provider.Mutable
}
func (p *Provider) ContentType(path string) string {
if isArtifact(path) {
return "application/gzip"
}
return "application/json"
}
func (p *Provider) UpstreamURL(remote models.Remote, path string) string {
return strings.TrimRight(remote.BaseURL, "/") + "/" + strings.TrimLeft(path, "/")
}
const maxPageLimit = 100
// ForwardedQuery keeps limit/offset pagination on metadata paths only.
func (p *Provider) ForwardedQuery(path string) map[string]provider.QueryValidator {
if isArtifact(path) {
return nil
}
return map[string]provider.QueryValidator{"limit": pageLimit, "offset": pageOffset}
}
func pageOffset(v string) (string, bool) {
n, err := strconv.ParseUint(v, 10, 32)
return strconv.FormatUint(n, 10), err == nil
}
func pageLimit(v string) (string, bool) {
n, err := strconv.ParseUint(v, 10, 32)
if err != nil || n == 0 {
return "", false
}
return strconv.FormatUint(min(n, maxPageLimit), 10), true
}
// RewriteResponse points href, download_url and links.* at the remote. Links
// stay root-relative, which ansible-galaxy joins onto the server URL, so the
// cached body is independent of the request host.
func (p *Provider) RewriteResponse(body []byte, remote models.Remote, _ string) ([]byte, error) {
dec := json.NewDecoder(bytes.NewReader(body))
dec.UseNumber()
var doc any
if err := dec.Decode(&doc); err != nil {
return nil, nil
}
r := rewriter{
prefix: provider.RemoteMountPath + url.PathEscape(remote.Name) + "/",
base: strings.TrimRight(remote.BaseURL, "/") + "/",
}
r.walk(doc)
var out bytes.Buffer
enc := json.NewEncoder(&out)
enc.SetEscapeHTML(false)
if err := enc.Encode(doc); err != nil {
return nil, err
}
return out.Bytes(), nil
}
func (p *Provider) AuthHeaders(_ context.Context, remote models.Remote) (http.Header, error) {
return auth.BasicHeaders(remote), nil
}
type rewriter struct{ prefix, base string }
func (r rewriter) walk(v any) {
switch v := v.(type) {
case []any:
for _, e := range v {
r.walk(e)
}
case map[string]any:
for k, e := range v {
switch k {
case "href", "download_url":
v[k] = r.link(e)
case "links":
if links, ok := e.(map[string]any); ok {
for lk, l := range links {
links[lk] = r.link(l)
}
continue
}
}
r.walk(v[k])
}
}
}
func (r rewriter) link(v any) any {
s, ok := v.(string)
switch {
case !ok:
return v
case strings.HasPrefix(s, "/api/"):
return r.prefix + s[1:]
case strings.HasPrefix(s, r.base):
return r.prefix + strings.TrimPrefix(s, r.base)
}
return s
}
func isArtifact(path string) bool {
path, _, _ = strings.Cut(path, "?")
return strings.Contains(path, "/artifacts/") && strings.HasSuffix(path, ".tar.gz")
}
+150
View File
@@ -0,0 +1,150 @@
package ansible
import (
"encoding/json"
"testing"
"git.unkin.net/unkin/artifactapi/internal/provider"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
const tarball = "api/v3/plugin/ansible/content/published/collections/artifacts/ansible-posix-2.0.0.tar.gz"
func TestClassify(t *testing.T) {
p := &Provider{}
tests := []struct {
path string
want provider.Mutability
}{
{"api/", provider.Mutable},
{"api/v3/collections/community/general/versions/?limit=100", provider.Mutable},
{"api/v3/collections/community/general/versions/?x=.tar.gz", provider.Mutable},
{"api/v3/collections/ansible/posix/versions/2.0.0/", provider.Mutable},
{"api/v3/collections/ansible/posix/readme.tar.gz", provider.Mutable},
{tarball, provider.Immutable},
{tarball + "?limit=1", provider.Immutable},
}
for _, tt := range tests {
if got := p.Classify(tt.path); got != tt.want {
t.Errorf("Classify(%q) = %v, want %v", tt.path, got, tt.want)
}
}
}
func TestContentTypeIgnoresQuery(t *testing.T) {
if got := (&Provider{}).ContentType("api/v3/collections/a/b/versions/?x=.tar.gz"); got != "application/json" {
t.Errorf("ContentType = %q, want application/json", got)
}
}
func TestForwardedQuery(t *testing.T) {
p := &Provider{}
if got := p.ForwardedQuery(tarball); got != nil {
t.Errorf("tarball ForwardedQuery = %v, want nil", got)
}
q := p.ForwardedQuery("api/v3/collections/a/b/versions/")
if len(q) != 2 || q["limit"] == nil || q["offset"] == nil {
t.Fatalf("metadata ForwardedQuery keys = %v, want limit and offset", q)
}
tests := []struct {
param, in, want string
ok bool
}{
{"limit", "10", "10", true},
{"limit", "007", "7", true},
{"limit", "100", "100", true},
{"limit", "5000", "100", true},
{"limit", "0", "", false},
{"limit", "", "", false},
{"limit", "-1", "", false},
{"limit", "+5", "", false},
{"limit", "1e3", "", false},
{"limit", "10 ", "", false},
{"offset", "0", "0", true},
{"offset", "000", "0", true},
{"offset", "0100", "100", true},
{"offset", "abc", "", false},
{"offset", "99999999999999999999", "", false},
}
for _, tt := range tests {
got, ok := q[tt.param](tt.in)
if ok != tt.ok || (ok && got != tt.want) {
t.Errorf("%s(%q) = %q, %v, want %q, %v", tt.param, tt.in, got, ok, tt.want, tt.ok)
}
}
}
func rewrite(t *testing.T, remote models.Remote, body string) map[string]any {
t.Helper()
out, err := (&Provider{}).RewriteResponse([]byte(body), remote, "")
if err != nil {
t.Fatal(err)
}
var got map[string]any
if err := json.Unmarshal(out, &got); err != nil {
t.Fatalf("rewritten body is not JSON: %v: %s", err, out)
}
return got
}
func TestRewriteResponse(t *testing.T) {
remote := models.Remote{Name: "galaxy", BaseURL: "https://galaxy.ansible.com/"}
got := rewrite(t, remote, `{
"links": {"next": "/api/v3/collections/a/b/versions/?limit=10&offset=10", "previous": null},
"data": [{
"href": "/api/v3/collections/a/b/versions/1.0.0/",
"download_url": "https://galaxy.ansible.com/api/v3/plugin/ansible/content/published/collections/artifacts/a-b-1.0.0.tar.gz",
"description": "/api/ is where it lives",
"docs": "/api/v3/not-a-link/",
"metadata": {"repository": "https://github.com/a/b", "homepage": "https://galaxy.ansible.com/ui/"}
}]
}`)
pre := "/api/v1/remote/galaxy/"
links := got["links"].(map[string]any)
if want := pre + "api/v3/collections/a/b/versions/?limit=10&offset=10"; links["next"] != want {
t.Errorf("links.next = %v, want %q", links["next"], want)
}
if links["previous"] != nil {
t.Errorf("links.previous = %v, want null", links["previous"])
}
item := got["data"].([]any)[0].(map[string]any)
if want := pre + "api/v3/collections/a/b/versions/1.0.0/"; item["href"] != want {
t.Errorf("href = %v, want %q", item["href"], want)
}
if want := pre + "api/v3/plugin/ansible/content/published/collections/artifacts/a-b-1.0.0.tar.gz"; item["download_url"] != want {
t.Errorf("download_url = %v, want %q", item["download_url"], want)
}
for k, want := range map[string]string{"description": "/api/ is where it lives", "docs": "/api/v3/not-a-link/"} {
if item[k] != want {
t.Errorf("%s rewritten to %v", k, item[k])
}
}
meta := item["metadata"].(map[string]any)
if meta["repository"] != "https://github.com/a/b" || meta["homepage"] != "https://galaxy.ansible.com/ui/" {
t.Errorf("unrelated URLs rewritten: %v", meta)
}
}
func TestRewriteResponseEscapesRemoteName(t *testing.T) {
got := rewrite(t, models.Remote{Name: `a"b/c`, BaseURL: "https://g/"}, `{"href":"/api/x/"}`)
if want := "/api/v1/remote/a%22b%2Fc/api/x/"; got["href"] != want {
t.Errorf("href = %v, want %q", got["href"], want)
}
}
func TestRewriteResponseKeepsLargeNumbers(t *testing.T) {
out, err := (&Provider{}).RewriteResponse([]byte(`{"n":12345678901234567890}`), models.Remote{Name: "g"}, "")
if err != nil {
t.Fatal(err)
}
if want := "{\"n\":12345678901234567890}\n"; string(out) != want {
t.Errorf("got %q, want %q", out, want)
}
}
func TestRewriteResponseSkipsNonJSON(t *testing.T) {
out, err := (&Provider{}).RewriteResponse([]byte("<html>"), models.Remote{Name: "g"}, "")
if err != nil || out != nil {
t.Errorf("got %q, %v; want nil, nil", out, err)
}
}
+347
View File
@@ -0,0 +1,347 @@
package ansible
import (
"archive/tar"
"compress/gzip"
"encoding/json"
"errors"
"fmt"
"io"
"net/url"
"path"
"regexp"
"strconv"
"strings"
"time"
)
// Local ansible repos speak the Galaxy v3 publish and install API. Per version:
// artifacts/<ns>-<name>-<version>.tar.gz collection tarball (immutable)
// _index/<ns>/<name>/<version> Entry JSON; gates visibility
const (
IndexPrefix = "_index/"
ArtifactPrefix = "artifacts/"
PublishPath = "api/v3/artifacts/collections/"
)
var (
NameRe = regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`)
VersionRe = regexp.MustCompile(`^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$`)
)
var (
MaxManifestBytes int64 = 1 << 20
MaxScanBytes int64 = 512 << 20
)
// Entry is one published collection version.
type Entry struct {
Namespace string `json:"namespace"`
Name string `json:"name"`
Version string `json:"version"`
SHA256 string `json:"sha256"`
Size int64 `json:"size"`
Metadata json.RawMessage `json:"metadata"`
CreatedAt time.Time `json:"created_at"`
}
func (e Entry) Filename() string { return e.Namespace + "-" + e.Name + "-" + e.Version + ".tar.gz" }
// IndexPath is the entry's hidden index file.
func (e Entry) IndexPath() string { return IndexPrefix + e.Namespace + "/" + e.Name + "/" + e.Version }
// TaskID names the import task returned on publish.
func (e Entry) TaskID() string { return e.Namespace + "-" + e.Name + "-" + e.Version }
// ParseCollection reads collection_info from a collection tarball's top-level
// MANIFEST.json. Every member is checked, so a tarball whose paths or links
// escape the collection root is rejected even after the manifest.
func ParseCollection(r io.Reader) (*Entry, error) {
zr, err := gzip.NewReader(r)
if err != nil {
return nil, fmt.Errorf("not a gzip tarball: %w", err)
}
scan := &io.LimitedReader{R: zr, N: MaxScanBytes}
tr := tar.NewReader(scan)
var e *Entry
for {
hdr, err := tr.Next()
if scan.N <= 0 {
return nil, errors.New("collection exceeds decompressed size limit")
}
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("read tarball: %w", err)
}
name, err := memberPath(hdr)
if err != nil {
return nil, err
}
if name != "MANIFEST.json" || hdr.Typeflag != tar.TypeReg {
continue
}
if e != nil {
return nil, errors.New("duplicate MANIFEST.json")
}
if e, err = parseManifest(io.LimitReader(tr, MaxManifestBytes+1)); err != nil {
return nil, err
}
}
if e == nil {
return nil, errors.New("no MANIFEST.json in collection tarball")
}
return e, nil
}
// memberPath returns hdr's cleaned name, rejecting absolute or escaping
// paths and links, and anything but files, directories and symlinks.
func memberPath(hdr *tar.Header) (string, error) {
name := path.Clean(strings.TrimPrefix(hdr.Name, "./"))
if escapes(name) {
return "", fmt.Errorf("tarball member %q escapes the collection root", hdr.Name)
}
switch hdr.Typeflag {
case tar.TypeReg, tar.TypeDir:
case tar.TypeSymlink:
if path.IsAbs(hdr.Linkname) || escapes(path.Join(path.Dir(name), hdr.Linkname)) {
return "", fmt.Errorf("tarball symlink %q escapes the collection root", hdr.Name)
}
default:
return "", fmt.Errorf("tarball member %q has unsupported type %q", hdr.Name, hdr.Typeflag)
}
return name, nil
}
func escapes(p string) bool {
return path.IsAbs(p) || p == ".." || strings.HasPrefix(p, "../") || strings.Contains(p, "\\")
}
func parseManifest(r io.Reader) (*Entry, error) {
raw, err := io.ReadAll(r)
if err != nil {
return nil, fmt.Errorf("read MANIFEST.json: %w", err)
}
if int64(len(raw)) > MaxManifestBytes {
return nil, fmt.Errorf("MANIFEST.json exceeds %d bytes", MaxManifestBytes)
}
var doc struct {
CollectionInfo map[string]json.RawMessage `json:"collection_info"`
}
if err := json.Unmarshal(raw, &doc); err != nil || doc.CollectionInfo == nil {
return nil, errors.New("MANIFEST.json has no collection_info")
}
info := doc.CollectionInfo
e := &Entry{}
for k, dst := range map[string]*string{"namespace": &e.Namespace, "name": &e.Name, "version": &e.Version} {
if err := json.Unmarshal(info[k], dst); err != nil {
return nil, fmt.Errorf("collection_info.%s is not a string", k)
}
}
if !NameRe.MatchString(e.Namespace) || !NameRe.MatchString(e.Name) || !VersionRe.MatchString(e.Version) {
return nil, fmt.Errorf("invalid collection %q.%q version %q", e.Namespace, e.Name, e.Version)
}
deps := map[string]string{}
if d := info["dependencies"]; len(d) > 0 && string(d) != "null" {
if err := json.Unmarshal(d, &deps); err != nil {
return nil, errors.New("collection_info.dependencies must map collection names to version ranges")
}
}
for k := range deps {
ns, n, ok := strings.Cut(k, ".")
if !ok || !NameRe.MatchString(ns) || !NameRe.MatchString(n) {
return nil, fmt.Errorf("invalid dependency %q", k)
}
}
info["dependencies"], _ = json.Marshal(deps)
if e.Metadata, err = json.Marshal(info); err != nil {
return nil, err
}
return e, nil
}
// APIPath is a parsed Galaxy v3 request path below a repo.
type APIPath struct {
Kind APIKind
Namespace, Name, Version string
TaskID string
}
type APIKind int
const (
APIUnknown APIKind = iota
APIRoot
APICollection
APIVersions
APIVersion
APIImport
)
// ParseAPIPath parses api/, api/v3/collections/<ns>/<name>/[versions/[<v>/]]
// and api/v3/imports/collections/<id>/, with or without trailing slashes.
func ParseAPIPath(p string) APIPath {
p = strings.TrimSuffix(p, "/")
if p == "api" {
return APIPath{Kind: APIRoot}
}
if id, ok := strings.CutPrefix(p, "api/v3/imports/collections/"); ok && id != "" && !strings.Contains(id, "/") {
return APIPath{Kind: APIImport, TaskID: id}
}
rest, ok := strings.CutPrefix(p, "api/v3/collections/")
if !ok {
return APIPath{}
}
segs := strings.Split(rest, "/")
if len(segs) < 2 || !NameRe.MatchString(segs[0]) || !NameRe.MatchString(segs[1]) {
return APIPath{}
}
ap := APIPath{Namespace: segs[0], Name: segs[1]}
switch {
case len(segs) == 2:
ap.Kind = APICollection
case len(segs) == 3 && segs[2] == "versions":
ap.Kind = APIVersions
case len(segs) == 4 && segs[2] == "versions" && VersionRe.MatchString(segs[3]):
ap.Kind, ap.Version = APIVersion, segs[3]
default:
return APIPath{}
}
return ap
}
// Root is the API root ansible-galaxy discovers.
func Root() map[string]any {
return map[string]any{"available_versions": map[string]string{"v3": "v3/"}}
}
func CollectionPath(ns, name string) string { return "/api/v3/collections/" + ns + "/" + name + "/" }
func VersionPath(ns, name, version string) string {
return CollectionPath(ns, name) + "versions/" + url.PathEscape(version) + "/"
}
// VersionRef is one entry of a versions list.
type VersionRef struct {
Version string `json:"version"`
CreatedAt string `json:"created_at,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
}
func (e Entry) Ref() VersionRef {
t := e.CreatedAt.UTC().Format(time.RFC3339Nano)
return VersionRef{Version: e.Version, CreatedAt: t, UpdatedAt: t}
}
// CollectionDoc describes a collection from its versions; base is the repo
// URL the hrefs are built on.
func CollectionDoc(base, ns, name string, refs []VersionRef) map[string]any {
var created, updated time.Time
for _, r := range refs {
if t, err := time.Parse(time.RFC3339Nano, r.CreatedAt); err == nil && (created.IsZero() || t.Before(created)) {
created = t
}
if t, err := time.Parse(time.RFC3339Nano, r.UpdatedAt); err == nil && t.After(updated) {
updated = t
}
}
return map[string]any{
"href": base + CollectionPath(ns, name),
"namespace": ns,
"name": name,
"deprecated": false,
"versions_url": base + CollectionPath(ns, name) + "versions/",
"created_at": formatTime(created),
"updated_at": formatTime(updated),
}
}
func formatTime(t time.Time) string {
if t.IsZero() {
return ""
}
return t.UTC().Format(time.RFC3339Nano)
}
// Page reads limit/offset as the remote forwards them, defaulting to 100/0.
func Page(q url.Values) (limit, offset int) {
limit, offset = maxPageLimit, 0
if v, ok := pageLimit(q.Get("limit")); ok {
limit, _ = strconv.Atoi(v)
}
if v, ok := pageOffset(q.Get("offset")); ok {
offset, _ = strconv.Atoi(v)
}
return limit, offset
}
// VersionsPage is one page of refs in Galaxy v3 list form. Links are
// root-relative like Galaxy's, since ansible-galaxy splices them onto the
// server URL.
func VersionsPage(base, ns, name string, refs []VersionRef, limit, offset int) map[string]any {
list := rootPath(base) + CollectionPath(ns, name) + "versions/"
link := func(off int) any {
return list + "?" + url.Values{"limit": {strconv.Itoa(limit)}, "offset": {strconv.Itoa(off)}}.Encode()
}
offset = min(offset, len(refs))
var previous, next any
if offset > 0 {
previous = link(max(offset-limit, 0))
}
if offset+limit < len(refs) {
next = link(offset + limit)
}
data := []map[string]any{}
for _, r := range refs[offset:min(offset+limit, len(refs))] {
data = append(data, map[string]any{
"version": r.Version,
"href": base + VersionPath(ns, name, r.Version),
"created_at": r.CreatedAt,
"updated_at": r.UpdatedAt,
"marks": []string{},
})
}
return map[string]any{
"meta": map[string]any{"count": len(refs)},
"links": map[string]any{"first": link(0), "previous": previous, "next": next, "last": link(max(len(refs)-1, 0) / limit * limit)},
"data": data,
}
}
// rootPath drops the scheme and host from base.
func rootPath(base string) string {
if u, err := url.Parse(base); err == nil {
return u.EscapedPath()
}
return base
}
// VersionDoc is a version's detail; the tarball is served from the repo at
// artifacts/<filename>.
func VersionDoc(base string, e Entry) map[string]any {
t := e.CreatedAt.UTC().Format(time.RFC3339Nano)
return map[string]any{
"href": base + VersionPath(e.Namespace, e.Name, e.Version),
"namespace": map[string]string{"name": e.Namespace},
"collection": map[string]string{"name": e.Name},
"name": e.Name,
"version": e.Version,
"download_url": base + "/" + ArtifactPrefix + e.Filename(),
"artifact": map[string]any{"filename": e.Filename(), "sha256": e.SHA256, "size": e.Size},
"metadata": e.Metadata,
"signatures": []string{},
"created_at": t,
"updated_at": t,
}
}
// ImportTask is a finished import: publishing completes synchronously.
func ImportTask(e Entry) map[string]any {
t := e.CreatedAt.UTC().Format(time.RFC3339Nano)
return map[string]any{
"id": e.TaskID(), "state": "completed", "created_at": t, "started_at": t, "finished_at": t,
"messages": []any{}, "error": nil,
}
}
+256
View File
@@ -0,0 +1,256 @@
package ansible
import (
"archive/tar"
"bytes"
"compress/gzip"
"encoding/json"
"fmt"
"net/url"
"strings"
"testing"
)
type member struct {
hdr tar.Header
body string
}
func collectionTar(t *testing.T, members ...member) []byte {
t.Helper()
var buf bytes.Buffer
zw := gzip.NewWriter(&buf)
tw := tar.NewWriter(zw)
for _, m := range members {
h := m.hdr
if h.Typeflag == 0 {
h.Typeflag = tar.TypeReg
}
if h.Typeflag == tar.TypeReg {
h.Size = int64(len(m.body))
}
h.Mode = 0o644
if err := tw.WriteHeader(&h); err != nil {
t.Fatal(err)
}
_, _ = tw.Write([]byte(m.body))
}
_ = tw.Close()
_ = zw.Close()
return buf.Bytes()
}
func manifest(ns, name, version, deps string) member {
return member{tar.Header{Name: "MANIFEST.json"}, `{"collection_info":{"namespace":"` + ns + `","name":"` + name +
`","version":"` + version + `","authors":["a"],"dependencies":` + deps + `}}`}
}
func TestParseCollection(t *testing.T) {
e, err := ParseCollection(bytes.NewReader(collectionTar(t,
member{tar.Header{Name: "plugins/", Typeflag: tar.TypeDir}, ""},
member{tar.Header{Name: "plugins/modules/hello.py"}, "x"},
member{tar.Header{Name: "docs/link", Typeflag: tar.TypeSymlink, Linkname: "../plugins/modules/hello.py"}, ""},
manifest("unkin", "e2e_hello", "1.0.0-rc.1+b1", `{"ansible.posix":">=1.0.0"}`),
)))
if err != nil {
t.Fatal(err)
}
if e.Namespace != "unkin" || e.Name != "e2e_hello" || e.Version != "1.0.0-rc.1+b1" || e.Filename() != "unkin-e2e_hello-1.0.0-rc.1+b1.tar.gz" {
t.Fatalf("entry = %+v", e)
}
var md struct {
Authors []string `json:"authors"`
Dependencies map[string]string `json:"dependencies"`
}
if err := json.Unmarshal(e.Metadata, &md); err != nil || md.Dependencies["ansible.posix"] != ">=1.0.0" || len(md.Authors) != 1 {
t.Fatalf("metadata = %s (%v)", e.Metadata, err)
}
e, err = ParseCollection(bytes.NewReader(collectionTar(t, manifest("a", "b", "1.0.0", "null"))))
if err != nil || !strings.Contains(string(e.Metadata), `"dependencies":{}`) {
t.Fatalf("null dependencies = %s (%v), want {}", e.Metadata, err)
}
}
func TestParseCollectionRejects(t *testing.T) {
ok := manifest("a", "b", "1.0.0", "{}")
tests := []struct {
name string
members []member
want string
}{
{"no manifest", []member{{tar.Header{Name: "README.md"}, "x"}}, "no MANIFEST.json"},
{"nested manifest only", []member{{tar.Header{Name: "x/MANIFEST.json"}, ok.body}}, "no MANIFEST.json"},
{"dotdot", []member{ok, {tar.Header{Name: "../evil"}, "x"}}, "escapes"},
{"dotdot after clean", []member{ok, {tar.Header{Name: "a/../../evil"}, "x"}}, "escapes"},
{"absolute", []member{ok, {tar.Header{Name: "/etc/passwd"}, "x"}}, "escapes"},
{"symlink escape", []member{ok, {tar.Header{Name: "a/l", Typeflag: tar.TypeSymlink, Linkname: "../../x"}, ""}}, "symlink"},
{"absolute symlink", []member{ok, {tar.Header{Name: "l", Typeflag: tar.TypeSymlink, Linkname: "/etc"}, ""}}, "symlink"},
{"hardlink", []member{ok, {tar.Header{Name: "l", Typeflag: tar.TypeLink, Linkname: "MANIFEST.json"}, ""}}, "unsupported type"},
{"duplicate manifest", []member{ok, ok}, "duplicate"},
{"bad namespace", []member{manifest("Bad", "b", "1.0.0", "{}")}, "invalid collection"},
{"bad version", []member{manifest("a", "b", "1.0", "{}")}, "invalid collection"},
{"bad dependency", []member{manifest("a", "b", "1.0.0", `{"nodot":"*"}`)}, "invalid dependency"},
{"list dependencies", []member{manifest("a", "b", "1.0.0", `["x.y"]`)}, "dependencies must map"},
{"huge manifest", []member{{tar.Header{Name: "MANIFEST.json"}, strings.Repeat(" ", int(MaxManifestBytes)+1)}}, "exceeds"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := ParseCollection(bytes.NewReader(collectionTar(t, tt.members...)))
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Fatalf("err = %v, want %q", err, tt.want)
}
})
}
if _, err := ParseCollection(strings.NewReader("not gzip")); err == nil {
t.Error("non-gzip input accepted")
}
}
func TestParseCollectionScanLimit(t *testing.T) {
old := MaxScanBytes
MaxScanBytes = 4096
defer func() { MaxScanBytes = old }()
_, err := ParseCollection(bytes.NewReader(collectionTar(t,
member{tar.Header{Name: "big"}, strings.Repeat("x", 8192)},
manifest("a", "b", "1.0.0", "{}"),
)))
if err == nil || !strings.Contains(err.Error(), "decompressed size limit") {
t.Fatalf("err = %v, want decompressed size limit", err)
}
}
func TestParseAPIPath(t *testing.T) {
tests := []struct {
in string
want APIPath
}{
{"api", APIPath{Kind: APIRoot}},
{"api/", APIPath{Kind: APIRoot}},
{"api/v3/collections/unkin/e2e_hello/", APIPath{Kind: APICollection, Namespace: "unkin", Name: "e2e_hello"}},
{"api/v3/collections/unkin/e2e_hello/versions", APIPath{Kind: APIVersions, Namespace: "unkin", Name: "e2e_hello"}},
{"api/v3/collections/unkin/e2e_hello/versions/1.0.0/", APIPath{Kind: APIVersion, Namespace: "unkin", Name: "e2e_hello", Version: "1.0.0"}},
{"api/v3/imports/collections/unkin-e2e_hello-1.0.0/", APIPath{Kind: APIImport, TaskID: "unkin-e2e_hello-1.0.0"}},
{"api/v3/collections/unkin/e2e_hello/versions/1.0/", APIPath{}},
{"api/v3/collections/../x/", APIPath{}},
{"api/v3/collections/unkin/e2e_hello/versions/1.0.0/extra/", APIPath{}},
{"artifacts/unkin-e2e_hello-1.0.0.tar.gz", APIPath{}},
{"api/v3/imports/collections/a/b/", APIPath{}},
}
for _, tt := range tests {
if got := ParseAPIPath(tt.in); got != tt.want {
t.Errorf("ParseAPIPath(%q) = %+v, want %+v", tt.in, got, tt.want)
}
}
}
func TestVersionsPage(t *testing.T) {
refs := []VersionRef{{Version: "3.0.0"}, {Version: "2.0.0"}, {Version: "1.0.0"}}
list := "/r/api/v3/collections/a/b/versions/"
links := func(p map[string]any) map[string]any { return p["links"].(map[string]any) }
versions := func(p map[string]any) (out []string) {
for _, d := range p["data"].([]map[string]any) {
out = append(out, d["version"].(string))
}
return out
}
p := VersionsPage("https://h/r", "a", "b", refs, 2, 0)
if got := versions(p); strings.Join(got, ",") != "3.0.0,2.0.0" {
t.Fatalf("page 1 = %v", got)
}
if l := links(p); l["previous"] != nil || l["next"] != list+"?limit=2&offset=2" || l["last"] != list+"?limit=2&offset=2" {
t.Fatalf("page 1 links = %v", l)
}
if p["meta"].(map[string]any)["count"] != 3 {
t.Fatalf("count = %v", p["meta"])
}
p = VersionsPage("https://h/r", "a", "b", refs, 2, 2)
if got := versions(p); strings.Join(got, ",") != "1.0.0" {
t.Fatalf("page 2 = %v", got)
}
if l := links(p); l["next"] != nil || l["previous"] != list+"?limit=2&offset=0" {
t.Fatalf("page 2 links = %v", l)
}
if got := versions(VersionsPage("", "a", "b", refs, 2, 99)); len(got) != 0 {
t.Fatalf("offset past end = %v, want empty", got)
}
}
// TestVersionsPageFollow pages through more than 100 versions the way
// ansible-galaxy does: old clients splice next onto the versions URL in place
// of its path, new ones urljoin it onto the server.
func TestVersionsPageFollow(t *testing.T) {
var refs []VersionRef
for i := 104; i >= 0; i-- {
refs = append(refs, VersionRef{Version: fmt.Sprintf("1.0.%d", i)})
}
server := "https://h:8443/api/v1/local/r%2Bx/"
base := strings.TrimSuffix(server, "/")
versionsURL := server + "api/v3/collections/a/b/versions/"
for _, follow := range map[string]func(string) string{
"replace": func(link string) string {
u, _ := url.Parse(versionsURL)
return strings.Replace(versionsURL, u.EscapedPath(), link, 1)
},
"urljoin": func(link string) string {
s, _ := url.Parse(server)
l, _ := url.Parse(link)
return s.ResolveReference(l).String()
},
} {
var got []string
next := versionsURL + "?limit=100"
for pages := 0; next != ""; pages++ {
if pages > 3 {
t.Fatal("pagination does not terminate")
}
u, err := url.Parse(next)
if err != nil || u.Host != "h:8443" || !strings.HasPrefix(u.EscapedPath(), "/api/v1/local/r%2Bx/api/v3/") {
t.Fatalf("followed link %q is not on the server", next)
}
limit, offset := Page(u.Query())
p := VersionsPage(base, "a", "b", refs, limit, offset)
for _, d := range p["data"].([]map[string]any) {
got = append(got, d["version"].(string))
}
next = ""
if l, ok := p["links"].(map[string]any)["next"].(string); ok {
next = follow(l)
}
}
if len(got) != 105 || got[0] != "1.0.104" || got[104] != "1.0.0" {
t.Fatalf("collected %d versions, first %v", len(got), got[:1])
}
}
}
func TestCollectionDocTimes(t *testing.T) {
d := CollectionDoc("", "a", "b", []VersionRef{
{CreatedAt: "2026-01-01T00:00:05Z", UpdatedAt: "2026-01-01T00:00:05Z"},
{CreatedAt: "2026-01-01T00:00:05.5Z", UpdatedAt: "2026-01-01T00:00:05.5Z"},
{CreatedAt: "2026-01-01T00:00:04.9Z", UpdatedAt: "2026-01-01T00:00:04.9Z"},
})
if d["created_at"] != "2026-01-01T00:00:04.9Z" || d["updated_at"] != "2026-01-01T00:00:05.5Z" {
t.Fatalf("created/updated = %v/%v", d["created_at"], d["updated_at"])
}
}
func TestPage(t *testing.T) {
for _, tt := range []struct {
q string
limit, offset int
}{
{"", 100, 0},
{"limit=5&offset=10", 5, 10},
{"limit=5000", 100, 0},
{"limit=0&offset=-1", 100, 0},
} {
q, _ := url.ParseQuery(tt.q)
if l, o := Page(q); l != tt.limit || o != tt.offset {
t.Errorf("Page(%q) = %d,%d want %d,%d", tt.q, l, o, tt.limit, tt.offset)
}
}
}
@@ -0,0 +1,6 @@
package provider
// ContentValidator checks an upload's bytes (spooled to localFile) before it is stored.
type ContentValidator interface {
ValidateContent(storagePath, localFile string) error
}
+12
View File
@@ -0,0 +1,12 @@
package provider
// QueryForwarder is a remote provider whose upstream answers depend on the
// query string (e.g. paginated JSON APIs). Only the named params for path are
// forwarded upstream and kept in the cache key, each as the canonical value
// its validator returns; a value the validator rejects is dropped.
type QueryForwarder interface {
ForwardedQuery(path string) map[string]QueryValidator
}
// QueryValidator canonicalises a raw query value, reporting false to drop it.
type QueryValidator func(v string) (string, bool)
+15 -14
View File
@@ -333,6 +333,9 @@ func (p *GitHubProvider) scanWithState(ctx context.Context, remote models.Remote
return newEtag, false, err
}
// A transiently failed asset fails the scan so the ETag is not advanced and
// the retry re-derives it; an invalid package is skipped as no retry fixes it.
var failed []error
seen := map[string]bool{}
for _, rel := range releases {
if rel.Draft {
@@ -361,10 +364,14 @@ func (p *GitHubProvider) scanWithState(ctx context.Context, remote models.Remote
meta, err := p.deriveAsset(ctx, remote, asset, fp)
if err != nil {
slog.Warn("github_deb: derive asset failed", "remote", remote.Name, "asset", asset.Name, "error", err)
if !errors.Is(err, provider.ErrInvalidPackage) {
failed = append(failed, fmt.Errorf("%s: %w", asset.Name, err))
}
continue
}
if err := store.InsertDebMetadata(ctx, meta); err != nil {
slog.Error("github_deb: insert metadata failed", "remote", remote.Name, "asset", asset.Name, "error", err)
failed = append(failed, fmt.Errorf("%s: %w", asset.Name, err))
continue
}
slog.Info("github_deb: derived asset", "remote", remote.Name, "name", meta.Name, "version", meta.Version, "arch", meta.Architecture)
@@ -376,7 +383,7 @@ func (p *GitHubProvider) scanWithState(ctx context.Context, remote models.Remote
_ = store.DeleteDebMetadata(ctx, remote.Name, fp)
}
}
return newEtag, true, nil
return newEtag, true, errors.Join(failed...)
}
type ghRelease struct {
@@ -468,7 +475,7 @@ func (p *GitHubProvider) deriveAsset(ctx context.Context, remote models.Remote,
Size: asset.Size,
}
if meta.Name == "" {
return nil, errors.New("control missing Package field")
return nil, fmt.Errorf("%w: control missing Package field", provider.ErrInvalidPackage)
}
// The Packages SHA256 must be the sha256 of the whole .deb. Prefer GitHub's
@@ -503,13 +510,13 @@ func (p *GitHubProvider) fetchControl(ctx context.Context, remote models.Remote,
}
control, complete, perr := controlFromPrefix(body)
if perr != nil {
return "", fmt.Errorf("parse deb control: %w", perr)
return "", fmt.Errorf("%w: parse deb control: %w", provider.ErrInvalidPackage, perr)
}
if complete {
return control, nil
}
if full || n >= p.headerMax {
return "", fmt.Errorf("control.tar not found within %d bytes of %s", n, downloadURL)
return "", fmt.Errorf("%w: control.tar not found within %d bytes of %s", provider.ErrInvalidPackage, n, downloadURL)
}
n *= 2
if n > p.headerMax {
@@ -567,7 +574,7 @@ func controlFromPrefix(prefix []byte) (control string, complete bool, err error)
}
// rangeGet returns the first n bytes of downloadURL. full is true when the
// response body was shorter than n (i.e. we already have the whole object).
// response holds the whole object.
func (p *GitHubProvider) rangeGet(ctx context.Context, remote models.Remote, downloadURL string, n int64) ([]byte, bool, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
if err != nil {
@@ -589,15 +596,9 @@ func (p *GitHubProvider) rangeGet(ctx context.Context, remote models.Remote, dow
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {
return nil, false, fmt.Errorf("range GET %s: status %d", downloadURL, resp.StatusCode)
return nil, false, fmt.Errorf("range GET: %w", provider.AssetStatusError(downloadURL, resp))
}
body, err := io.ReadAll(io.LimitReader(resp.Body, n))
if err != nil {
return nil, false, err
}
full := int64(len(body)) < n
return body, full, nil
return provider.ReadPrefix(resp, n)
}
func (p *GitHubProvider) computeSHA256(ctx context.Context, remote models.Remote, downloadURL string) (string, error) {
@@ -620,7 +621,7 @@ func (p *GitHubProvider) computeSHA256(ctx context.Context, remote models.Remote
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("GET %s: status %d", downloadURL, resp.StatusCode)
return "", fmt.Errorf("GET: %w", provider.AssetStatusError(downloadURL, resp))
}
h := sha256.New()
+22 -3
View File
@@ -7,6 +7,7 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -81,15 +82,17 @@ type githubFixture struct {
notModHit int
releaseAuth string
assetAuth string
assetFail map[string]int // asset filename -> downloads left to answer 500
mu sync.Mutex
}
func newGitHubFixture(t *testing.T, withDigest bool) *githubFixture {
t.Helper()
f := &githubFixture{
debBytes: map[string][]byte{},
rangeHit: map[string]int{},
fullHit: map[string]int{},
debBytes: map[string][]byte{},
rangeHit: map[string]int{},
fullHit: map[string]int{},
assetFail: map[string]int{},
}
f.debBytes["demo_1.2-3_amd64.deb"] = testsupport.MinimalDeb("demo", "1.2-3", "amd64")
@@ -139,6 +142,12 @@ func newGitHubFixture(t *testing.T, withDigest bool) *githubFixture {
}
rng := r.Header.Get("Range")
f.mu.Lock()
if f.assetFail[name] > 0 {
f.assetFail[name]--
f.mu.Unlock()
http.Error(w, "boom", http.StatusInternalServerError)
return
}
f.assetAuth = r.Header.Get("Authorization")
if rng != "" {
f.rangeHit[name]++
@@ -460,3 +469,13 @@ func TestGitHubAssetPatternFilter(t *testing.T) {
t.Fatalf("pattern filter failed, rows=%+v", rows)
}
}
func TestGitHubDeriveAssetMissingPackageIsInvalid(t *testing.T) {
fx := newGitHubFixture(t, true)
fx.debBytes["nameless_1_amd64.deb"] = testsupport.MinimalDeb("", "1", "amd64")
asset := ghAsset{BrowserDownloadURL: fx.srv.URL + "/acme/tools/releases/download/v1.2-3/nameless_1_amd64.deb", Digest: "sha256:00"}
_, err := newTestProvider().deriveAsset(context.Background(), fx.remote(), asset, "fp")
if !errors.Is(err, provider.ErrInvalidPackage) {
t.Fatalf("want ErrInvalidPackage, got %v", err)
}
}
+11 -8
View File
@@ -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
+97 -2
View File
@@ -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)
}
@@ -308,3 +314,92 @@ func TestSyncerPrimeBypassesRecencyPeriodicDoesNot(t *testing.T) {
t.Fatalf("periodic scan ran inside recency window: %d -> %d releases calls", releasesAfterPrime, fx.releasesHit)
}
}
// A transiently failed asset fails the scan: the ETag is not advanced, a retry
// is scheduled, and the retry against the unchanged release list derives it. An
// invalid package is skipped instead, since no retry can fix it.
func TestSyncerFailedAssetRetry(t *testing.T) {
for _, tc := range []struct {
name string
corrupt bool
wantRetry bool
}{
{name: "transient", wantRetry: true},
{name: "invalid package", corrupt: true},
} {
t.Run(tc.name, func(t *testing.T) {
fx := newGitHubFixture(t, true)
fx.etag = `"v1"`
if tc.corrupt {
fx.debBytes["other_9-9_arm64.deb"] = []byte("not a package")
} else {
fx.debBytes["other_9-9_arm64.deb"] = testsupport.MinimalDeb("other", "9-9", "arm64")
fx.assetFail["other_9-9_arm64.deb"] = 1
}
store := newFakeSyncStore()
s := newSyncer(store, newTestProvider(), testSyncConfig())
remote := fx.remote()
bg := context.Background()
s.process(bg, syncJob{remote: remote, prime: true})
_, pending := store.retryAt[remote.Name]
if pending != tc.wantRetry {
t.Fatalf("retry pending=%v, want %v", pending, tc.wantRetry)
}
if _, saved := store.etags[remote.Name]; saved == tc.wantRetry {
t.Fatalf("etag saved=%v after first scan", saved)
}
if rows, _ := store.ListDebMetadataEntries(bg, remote.Name); len(rows) != 1 {
t.Fatalf("healthy asset not served: %d rows", len(rows))
}
if !tc.wantRetry {
return
}
store.mu.Lock()
store.retryAt[remote.Name] = time.Now().Add(-time.Second)
store.mu.Unlock()
s.process(bg, syncJob{remote: remote})
if _, pending := store.retryAt[remote.Name]; pending || store.etags[remote.Name] != `"v1"` {
t.Fatalf("retry not recorded: pending=%v etag=%q", pending, store.etags[remote.Name])
}
if rows, _ := store.ListDebMetadataEntries(bg, remote.Name); len(rows) != 2 {
t.Fatalf("retry did not derive the failed asset: %d rows", len(rows))
}
})
}
}
// 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)
}
}
+468
View File
@@ -0,0 +1,468 @@
package gems
import (
"archive/tar"
"bytes"
"compress/gzip"
"compress/zlib"
"crypto/md5"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"maps"
"regexp"
"slices"
"strings"
"time"
"gopkg.in/yaml.v3"
)
var (
NameRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
VersionRe = regexp.MustCompile(`^[0-9]+(\.[0-9A-Za-z]+)*(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$`)
PlatformRe = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`)
opRe = regexp.MustCompile(`^(=|!=|>|<|>=|<=|~>)$`)
)
const maxMetadataBytes = 8 << 20
type Requirement [][2]string
type Dependency struct {
Name string
Requirement Requirement
Type string
Prerelease bool
}
// Spec is the subset of a gemspec the compact index and quick spec need.
type Spec struct {
Name, Version, Platform string
Summary, RubyGemsVer string
SpecVersion int
Date time.Time
Authors, Licenses []string
Email, Description string
Homepage string
Metadata map[string]string
Dependencies []Dependency
RequiredRuby Requirement
RequiredRubyGems Requirement
}
// FullName is the gem's file stem: name-version, plus -platform when not ruby.
func (s *Spec) FullName() string {
if s.Platform == "" || s.Platform == "ruby" {
return s.Name + "-" + s.Version
}
return s.Name + "-" + s.Version + "-" + s.Platform
}
type yamlReq struct {
Requirements [][]yaml.Node `yaml:"requirements"`
}
func (r *yamlReq) decode() (Requirement, error) {
if r == nil {
return nil, nil
}
var out Requirement
for _, pair := range r.Requirements {
var op string
var ver struct {
Version string `yaml:"version"`
}
if len(pair) != 2 || pair[0].Decode(&op) != nil || pair[1].Decode(&ver) != nil {
return nil, errors.New("malformed requirement")
}
if !opRe.MatchString(op) || !VersionRe.MatchString(ver.Version) {
return nil, fmt.Errorf("invalid requirement %q %q", op, ver.Version)
}
out = append(out, [2]string{op, ver.Version})
}
return out, nil
}
// String renders the requirement as the compact index does: "op ver&op ver".
func (r Requirement) String() string {
parts := make([]string, len(r))
for i, p := range r {
parts[i] = p[0] + " " + p[1]
}
return strings.Join(parts, "&")
}
func (r Requirement) trivial() bool {
return len(r) == 0 || len(r) == 1 && r[0] == [2]string{">=", "0"}
}
// ParseGem reads the gemspec from a .gem archive (a tar holding metadata.gz).
func ParseGem(gem []byte) (*Spec, error) {
tr := tar.NewReader(bytes.NewReader(gem))
for {
hdr, err := tr.Next()
if err == io.EOF {
return nil, errors.New("gem has no metadata.gz")
}
if err != nil {
return nil, fmt.Errorf("read gem: %w", err)
}
if hdr.Name != "metadata.gz" {
continue
}
zr, err := gzip.NewReader(tr)
if err != nil {
return nil, fmt.Errorf("metadata.gz: %w", err)
}
raw, err := io.ReadAll(io.LimitReader(zr, maxMetadataBytes+1))
if err != nil {
return nil, fmt.Errorf("metadata.gz: %w", err)
}
if len(raw) > maxMetadataBytes {
return nil, fmt.Errorf("metadata exceeds %d bytes", maxMetadataBytes)
}
return parseMetadata(raw)
}
}
func parseMetadata(raw []byte) (*Spec, error) {
var doc struct {
Name string `yaml:"name"`
Version struct {
Version string `yaml:"version"`
} `yaml:"version"`
Platform string `yaml:"platform"`
Summary string `yaml:"summary"`
Authors []string `yaml:"authors"`
Email yaml.Node `yaml:"email"`
Description string `yaml:"description"`
Homepage string `yaml:"homepage"`
Licenses []string `yaml:"licenses"`
Metadata map[string]string `yaml:"metadata"`
Date string `yaml:"date"`
RubyGemsVer string `yaml:"rubygems_version"`
SpecVersion int `yaml:"specification_version"`
Dependencies []struct {
Name string `yaml:"name"`
Requirement *yamlReq `yaml:"requirement"`
VersionReqs *yamlReq `yaml:"version_requirements"`
Type string `yaml:"type"`
Prerelease bool `yaml:"prerelease"`
} `yaml:"dependencies"`
RequiredRuby *yamlReq `yaml:"required_ruby_version"`
RequiredRubyGems *yamlReq `yaml:"required_rubygems_version"`
}
if err := yaml.Unmarshal(raw, &doc); err != nil {
return nil, fmt.Errorf("gemspec yaml: %w", err)
}
s := &Spec{
Name: doc.Name, Version: doc.Version.Version, Platform: doc.Platform,
Summary: doc.Summary, Authors: doc.Authors, Description: doc.Description,
Homepage: doc.Homepage, Licenses: doc.Licenses, Metadata: doc.Metadata,
RubyGemsVer: doc.RubyGemsVer, SpecVersion: doc.SpecVersion,
}
if s.Platform == "" {
s.Platform = "ruby"
}
if !NameRe.MatchString(s.Name) || !VersionRe.MatchString(s.Version) || !PlatformRe.MatchString(s.Platform) {
return nil, fmt.Errorf("invalid gem name/version/platform %q %q %q", s.Name, s.Version, s.Platform)
}
if doc.Email.Kind == yaml.ScalarNode {
s.Email = doc.Email.Value
}
if t, err := time.Parse("2006-01-02", strings.SplitN(doc.Date, " ", 2)[0]); err == nil {
s.Date = t
}
var err error
if s.RequiredRuby, err = doc.RequiredRuby.decode(); err != nil {
return nil, err
}
if s.RequiredRubyGems, err = doc.RequiredRubyGems.decode(); err != nil {
return nil, err
}
for _, d := range doc.Dependencies {
if !NameRe.MatchString(d.Name) {
return nil, fmt.Errorf("invalid dependency name %q", d.Name)
}
req := d.Requirement
if req == nil {
req = d.VersionReqs
}
r, err := req.decode()
if err != nil {
return nil, fmt.Errorf("dependency %s: %w", d.Name, err)
}
typ := strings.TrimPrefix(d.Type, ":")
if typ == "" {
typ = "runtime"
}
s.Dependencies = append(s.Dependencies, Dependency{Name: d.Name, Requirement: r, Type: typ, Prerelease: d.Prerelease})
}
return s, nil
}
// InfoLine renders the spec's compact index /info line; checksum is the
// .gem's sha256 hex.
func InfoLine(s *Spec, gem []byte) string {
sum := sha256.Sum256(gem)
var deps []string
for _, d := range s.Dependencies {
if d.Type == "runtime" {
r := d.Requirement
if len(r) == 0 {
r = Requirement{{">=", "0"}}
}
deps = append(deps, d.Name+":"+r.String())
}
}
key := s.Version
if s.Platform != "ruby" {
key += "-" + s.Platform
}
line := key + " " + strings.Join(deps, ",") + "|checksum:" + hex.EncodeToString(sum[:])
if !s.RequiredRuby.trivial() {
line += ",ruby:" + s.RequiredRuby.String()
}
if !s.RequiredRubyGems.trivial() {
line += ",rubygems:" + s.RequiredRubyGems.String()
}
return line
}
// InfoBody renders an /info document from its lines.
func InfoBody(lines []string) []byte {
var b strings.Builder
b.WriteString("---\n")
for _, l := range lines {
b.WriteString(l)
b.WriteByte('\n')
}
return []byte(b.String())
}
// InfoKey returns an /info line's version[-platform] token.
func InfoKey(line string) string {
k, _, _ := strings.Cut(line, " ")
return k
}
// InfoLines returns an /info body's version lines.
func InfoLines(body []byte) []string {
var out []string
for _, l := range strings.Split(string(body), "\n") {
if l != "" && l != "---" {
out = append(out, l)
}
}
return out
}
func MD5Hex(b []byte) string {
sum := md5.Sum(b)
return hex.EncodeToString(sum[:])
}
// VersionsLine renders one /versions entry from a gem's /info body.
func VersionsLine(name string, info []byte) string {
var keys []string
for _, l := range InfoLines(info) {
keys = append(keys, InfoKey(l))
}
return name + " " + strings.Join(keys, ",") + " " + MD5Hex(info)
}
// VersionsHeader starts a /versions document.
func VersionsHeader(at time.Time) string {
return "created_at: " + at.UTC().Format(time.RFC3339) + "\n---\n"
}
// QuickSpec returns the deflated Marshal.4.8 dump of the spec, as served at
// quick/Marshal.4.8/<full>.gemspec.rz.
func QuickSpec(s *Spec) ([]byte, error) {
var buf bytes.Buffer
zw := zlib.NewWriter(&buf)
if _, err := zw.Write(MarshalSpec(s)); err != nil {
return nil, err
}
if err := zw.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// MarshalSpec mirrors Gem::Specification#_dump wrapped by Marshal.dump.
func MarshalSpec(s *Spec) []byte {
var in rbMarshal
in.WriteString("\x04\x08")
in.array(19)
in.str(s.RubyGemsVer)
in.WriteByte('i')
in.fixnum(s.SpecVersion)
in.str(s.Name)
in.version(s.Version)
in.time(s.Date)
in.str(s.Summary)
in.requirement(s.RequiredRuby)
in.requirement(s.RequiredRubyGems)
in.str(s.Platform)
in.array(len(s.Dependencies))
for _, d := range s.Dependencies {
in.WriteByte('o')
in.sym("Gem::Dependency")
in.fixnum(4)
in.sym("@name")
in.str(d.Name)
in.sym("@requirement")
in.requirement(d.Requirement)
in.sym("@type")
in.sym(d.Type)
in.sym("@prerelease")
in.boolean(d.Prerelease)
}
in.str("")
in.optStr(s.Email)
in.strs(s.Authors)
in.optStr(s.Description)
in.optStr(s.Homepage)
in.boolean(true)
in.str(s.Platform)
in.strs(s.Licenses)
in.WriteByte('{')
in.fixnum(len(s.Metadata))
for _, k := range slices.Sorted(maps.Keys(s.Metadata)) {
in.str(k)
in.str(s.Metadata[k])
}
var out rbMarshal
out.WriteString("\x04\x08u")
out.sym("Gem::Specification")
out.fixnum(in.Len())
out.Write(in.Bytes())
return out.Bytes()
}
// rbMarshal writes the Ruby Marshal 4.8 subset gemspecs use. It never emits
// object or symbol links, so every symbol is written in full.
type rbMarshal struct{ bytes.Buffer }
func (m *rbMarshal) fixnum(n int) {
switch {
case n == 0:
m.WriteByte(0)
case n > 0 && n < 123:
m.WriteByte(byte(n + 5))
case n < 0 && n > -124:
m.WriteByte(byte(n - 5))
default:
var b []byte
for x := n; len(b) < 4; x >>= 8 {
b = append(b, byte(x))
if (n >= 0 && x>>8 == 0) || (n < 0 && x>>8 == -1) {
break
}
}
if n >= 0 {
m.WriteByte(byte(len(b)))
} else {
m.WriteByte(byte(-len(b)))
}
m.Write(b)
}
}
func (m *rbMarshal) raw(s string) {
m.fixnum(len(s))
m.WriteString(s)
}
func (m *rbMarshal) sym(s string) {
m.WriteByte(':')
m.raw(s)
}
// str writes a UTF-8 String (an ivar-wrapped string with E=true).
func (m *rbMarshal) str(s string) {
m.WriteString("I\"")
m.raw(s)
m.fixnum(1)
m.sym("E")
m.WriteByte('T')
}
func (m *rbMarshal) optStr(s string) {
if s == "" {
m.WriteByte('0')
return
}
m.str(s)
}
func (m *rbMarshal) strs(ss []string) {
m.array(len(ss))
for _, s := range ss {
m.str(s)
}
}
func (m *rbMarshal) array(n int) {
m.WriteByte('[')
m.fixnum(n)
}
func (m *rbMarshal) boolean(b bool) {
if b {
m.WriteByte('T')
} else {
m.WriteByte('F')
}
}
func (m *rbMarshal) version(v string) {
m.WriteByte('U')
m.sym("Gem::Version")
m.array(1)
m.str(v)
}
func (m *rbMarshal) requirement(r Requirement) {
if len(r) == 0 {
r = Requirement{{">=", "0"}}
}
m.WriteByte('U')
m.sym("Gem::Requirement")
m.array(1)
m.array(len(r))
for _, p := range r {
m.array(2)
m.str(p[0])
m.version(p[1])
}
}
// time writes a UTC Time with day resolution, as Time#_dump lays it out.
func (m *rbMarshal) time(t time.Time) {
t = t.UTC()
// Time#_dump holds year-1900 in 16 bits.
if t.IsZero() || t.Year() < 1900 || t.Year() > 1900+0xffff {
t = time.Unix(0, 0).UTC()
}
p := uint32(1)<<31 | uint32(1)<<30 | uint32(t.Year()-1900)<<14 | uint32(t.Month()-1)<<10 | uint32(t.Day())<<5 | uint32(t.Hour())
s := uint32(t.Minute())<<26 | uint32(t.Second())<<20
m.WriteString("Iu")
m.sym("Time")
m.fixnum(8)
for _, w := range []uint32{p, s} {
m.Write([]byte{byte(w), byte(w >> 8), byte(w >> 16), byte(w >> 24)})
}
m.fixnum(1)
m.sym("zone")
m.WriteString("I\"")
m.raw("UTC")
m.fixnum(1)
m.sym("E")
m.WriteByte('F')
}
+223
View File
@@ -0,0 +1,223 @@
package gems
import (
"archive/tar"
"bytes"
"compress/gzip"
"compress/zlib"
"crypto/sha256"
"encoding/hex"
"io"
"strings"
"testing"
"time"
)
const testMetadata = `--- !ruby/object:Gem::Specification
name: unkin-e2e-hello
version: !ruby/object:Gem::Version
version: 0.1.0
platform: %PLATFORM%
authors:
- e2e
date: 2026-10-17 00:00:00.000000000 Z
dependencies:
- !ruby/object:Gem::Dependency
name: rack
requirement: &1 !ruby/object:Gem::Requirement
requirements:
- - ">="
- !ruby/object:Gem::Version
version: '2.0'
- - "<"
- !ruby/object:Gem::Version
version: '4'
type: :runtime
prerelease: false
version_requirements: *1
- !ruby/object:Gem::Dependency
name: json
version_requirements: !ruby/object:Gem::Requirement
requirements:
- - "~>"
- !ruby/object:Gem::Version
version: '2.1'
type: :runtime
prerelease: false
- !ruby/object:Gem::Dependency
name: rake
requirement: !ruby/object:Gem::Requirement
requirements:
- - ">="
- !ruby/object:Gem::Version
version: '0'
type: :development
prerelease: false
email:
licenses:
- MIT
metadata:
source_code_uri: https://example.com
required_ruby_version: !ruby/object:Gem::Requirement
requirements:
- - ">="
- !ruby/object:Gem::Version
version: '2.7'
required_rubygems_version: !ruby/object:Gem::Requirement
requirements:
- - ">="
- !ruby/object:Gem::Version
version: '0'
rubygems_version: 3.5.22
specification_version: 4
summary: hi
`
func buildGem(t *testing.T, metadata []byte) []byte {
t.Helper()
var gz bytes.Buffer
zw := gzip.NewWriter(&gz)
_, _ = zw.Write(metadata)
_ = zw.Close()
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
for name, body := range map[string][]byte{"data.tar.gz": []byte("x"), "metadata.gz": gz.Bytes()} {
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o444, Size: int64(len(body))}); err != nil {
t.Fatal(err)
}
_, _ = tw.Write(body)
}
_ = tw.Close()
return buf.Bytes()
}
func testGem(t *testing.T, platform string) []byte {
return buildGem(t, []byte(strings.Replace(testMetadata, "%PLATFORM%", platform, 1)))
}
func TestParseGemAndInfoLine(t *testing.T) {
gem := testGem(t, "ruby")
s, err := ParseGem(gem)
if err != nil {
t.Fatal(err)
}
if s.FullName() != "unkin-e2e-hello-0.1.0" || !s.Date.Equal(time.Date(2026, 10, 17, 0, 0, 0, 0, time.UTC)) {
t.Fatalf("spec = %+v", s)
}
if len(s.Dependencies) != 3 || s.Dependencies[1].Requirement.String() != "~> 2.1" {
t.Fatalf("deps = %+v", s.Dependencies)
}
sum := sha256.Sum256(gem)
want := "0.1.0 rack:>= 2.0&< 4,json:~> 2.1|checksum:" + hex.EncodeToString(sum[:]) + ",ruby:>= 2.7"
if got := InfoLine(s, gem); got != want {
t.Fatalf("InfoLine =\n %s\nwant\n %s", got, want)
}
plat, err := ParseGem(testGem(t, "x86_64-linux"))
if err != nil {
t.Fatal(err)
}
if plat.FullName() != "unkin-e2e-hello-0.1.0-x86_64-linux" || InfoKey(InfoLine(plat, nil)) != "0.1.0-x86_64-linux" {
t.Fatalf("platform gem: %s / %s", plat.FullName(), InfoLine(plat, nil))
}
}
func TestParseGemRejects(t *testing.T) {
big := []byte(testMetadata + "description: " + strings.Repeat("a", maxMetadataBytes) + "\n")
cases := map[string][]byte{
"not a tar": []byte("garbage"),
"no metadata": func() []byte { var b bytes.Buffer; tw := tar.NewWriter(&b); _ = tw.Close(); return b.Bytes() }(),
"bad name": buildGem(t, []byte(strings.Replace(testMetadata, "name: unkin-e2e-hello", "name: ../x", 1))),
"bad version": buildGem(t, []byte(strings.Replace(testMetadata, "version: 0.1.0", "version: 'a b'", 1))),
"bad dep op": buildGem(t, []byte(strings.Replace(testMetadata, `- - "~>"`, `- - "=~"`, 1))),
"metadata bomb": buildGem(t, big),
"bad platform": buildGem(t, []byte(strings.Replace(testMetadata, "%PLATFORM%", "x/y", 1))),
"bad dependency": buildGem(t, []byte(strings.Replace(testMetadata, "name: json", "name: 'a|b'", 1))),
}
for name, gem := range cases {
if _, err := ParseGem(gem); err == nil {
t.Errorf("%s: accepted", name)
}
}
}
func TestVersionsLineMD5(t *testing.T) {
info := InfoBody([]string{"0.1.0 |checksum:aa", "0.2.0-java |checksum:bb"})
if string(info) != "---\n0.1.0 |checksum:aa\n0.2.0-java |checksum:bb\n" {
t.Fatalf("InfoBody = %q", info)
}
if got := VersionsLine("g", info); got != "g 0.1.0,0.2.0-java "+MD5Hex(info) {
t.Fatalf("VersionsLine = %q", got)
}
}
// Byte layouts below are taken from Ruby 3.3's Marshal.dump.
func TestRubyMarshalEncoding(t *testing.T) {
for n, want := range map[int]string{0: "\x00", 4: "\x09", 122: "\x7f", 123: "\x01\x7b", 255: "\x01\xff", 256: "\x02\x00\x01", 65536: "\x03\x00\x00\x01", -1: "\xfa", -123: "\x80"} {
var m rbMarshal
m.fixnum(n)
if m.String() != want {
t.Errorf("fixnum(%d) = %x, want %x", n, m.String(), want)
}
}
var m rbMarshal
m.str("3.5.22")
if m.String() != "I\"\x0b3.5.22\x06:\x06ET" {
t.Errorf("str = %q", m.String())
}
m.Reset()
m.version("0.1.0")
if m.String() != "U:\x11Gem::Version[\x06I\"\x0a0.1.0\x06:\x06ET" {
t.Errorf("version = %q", m.String())
}
m.Reset()
m.time(time.Date(2026, 10, 17, 0, 0, 0, 0, time.UTC))
if want := "Iu:\x09Time\x0d\x20\xa6\x1f\xc0\x00\x00\x00\x00\x06:\x09zoneI\"\x08UTC\x06:\x06EF"; m.String() != want {
t.Errorf("time = %q, want %q", m.String(), want)
}
var epoch rbMarshal
epoch.time(time.Time{})
for _, y := range []int{1, 1899, 1900 + 0x10000} {
m.Reset()
m.time(time.Date(y, 1, 1, 0, 0, 0, 0, time.UTC))
if m.String() != epoch.String() {
t.Errorf("time(year %d) = %q, want the epoch encoding %q", y, m.String(), epoch.String())
}
}
}
func TestQuickSpec(t *testing.T) {
s, err := ParseGem(testGem(t, "ruby"))
if err != nil {
t.Fatal(err)
}
q, err := QuickSpec(s)
if err != nil {
t.Fatal(err)
}
q2, _ := QuickSpec(s)
if !bytes.Equal(q, q2) {
t.Fatal("QuickSpec is not deterministic")
}
zr, err := zlib.NewReader(bytes.NewReader(q))
if err != nil {
t.Fatal(err)
}
raw, _ := io.ReadAll(zr)
head := "\x04\x08u:\x17Gem::Specification"
if !strings.HasPrefix(string(raw), head) {
t.Fatalf("quick spec prefix = %q", raw[:30])
}
inner := string(raw[len(head):])
at := strings.Index(inner, "\x04\x08[\x18")
var length rbMarshal
length.fixnum(len(inner) - at)
if at < 1 || inner[:at] != length.String() {
t.Fatalf("inner dump header = %q", inner[:8])
}
for _, want := range []string{"o:\x14Gem::Dependency", ":\x0cruntime", ":\x10development", "source_code_uri"} {
if !strings.Contains(string(raw), want) {
t.Errorf("quick spec missing %q", want)
}
}
}
+3 -3
View File
@@ -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
+223
View File
@@ -0,0 +1,223 @@
package goproxy
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"slices"
"strings"
"golang.org/x/mod/modfile"
"golang.org/x/mod/module"
"golang.org/x/mod/semver"
modzip "golang.org/x/mod/zip"
"git.unkin.net/unkin/artifactapi/internal/provider"
)
type versionFile struct {
module string
version string
ext string
}
// parseVersionFile splits "<escaped module>/@v/<version>.<ext>" into its parts.
func parseVersionFile(path string) (versionFile, error) {
escMod, file, ok := strings.Cut(strings.TrimPrefix(path, "/"), "/@v/")
if !ok || escMod == "" || strings.Contains(file, "/") {
return versionFile{}, fmt.Errorf("path %q must be <module>/@v/<version>.{zip,mod,info}", path)
}
dot := strings.LastIndex(file, ".")
if dot <= 0 {
return versionFile{}, fmt.Errorf("path %q must end in .zip, .mod or .info", path)
}
ext := file[dot+1:]
if ext != "zip" && ext != "mod" && ext != "info" {
return versionFile{}, fmt.Errorf("path %q must end in .zip, .mod or .info", path)
}
mod, err := module.UnescapePath(escMod)
if err != nil {
return versionFile{}, fmt.Errorf("module path must be case-encoded (uppercase as !lower): %w", err)
}
ver, err := module.UnescapeVersion(file[:dot])
if err != nil {
return versionFile{}, err
}
if err := module.Check(mod, ver); err != nil {
return versionFile{}, err
}
if semver.Canonical(ver) != ver {
return versionFile{}, fmt.Errorf("version %q is not canonical semver", ver)
}
return versionFile{module: mod, version: ver, ext: ext}, nil
}
func (v versionFile) storagePath() string {
escMod, _ := module.EscapePath(v.module)
escVer, _ := module.EscapeVersion(v.version)
return escMod + "/@v/" + escVer + "." + v.ext
}
func (p *Provider) ValidateUpload(filePath string) (storagePath, contentType string, err error) {
vf, err := parseVersionFile(filePath)
if err != nil {
return "", "", err
}
sp := vf.storagePath()
return sp, p.ContentType(sp), nil
}
func (p *Provider) ValidateContent(storagePath, localFile string) error {
vf, err := parseVersionFile(storagePath)
if err != nil {
return err
}
switch vf.ext {
case "zip":
if _, err := modzip.CheckZip(module.Version{Path: vf.module, Version: vf.version}, localFile); err != nil {
return fmt.Errorf("invalid module zip (files must sit under %s@%s/): %w", vf.module, vf.version, err)
}
case "mod":
data, err := os.ReadFile(localFile)
if err != nil {
return err
}
mf, err := modfile.ParseLax("go.mod", data, nil)
if err != nil {
return fmt.Errorf("invalid go.mod: %w", err)
}
if mf.Module == nil || mf.Module.Mod.Path != vf.module {
return fmt.Errorf("go.mod must declare module %s", vf.module)
}
case "info":
data, err := os.ReadFile(localFile)
if err != nil {
return err
}
var info struct{ Version string }
if err := json.Unmarshal(data, &info); err != nil {
return fmt.Errorf("invalid .info JSON: %w", err)
}
if info.Version != vf.version {
return fmt.Errorf(".info Version %q does not match %s", info.Version, vf.version)
}
}
return nil
}
func (p *Provider) UploadResponse(storagePath, contentHash string, sizeBytes int64) map[string]any {
vf, _ := parseVersionFile(storagePath)
return map[string]any{
"module": vf.module,
"version": vf.version,
"path": storagePath,
"content_hash": contentHash,
"size_bytes": sizeBytes,
}
}
// versions returns the canonical versions of escMod that have a .mod uploaded, sorted ascending.
func versions(ctx context.Context, files provider.FileStore, repoName, escMod string) (versions []string, infos map[string]bool, err error) {
entries, err := files.ListFilesByPrefix(ctx, repoName, escMod+"/@v/")
if err != nil {
return nil, nil, err
}
infos = map[string]bool{}
for _, e := range entries {
vf, err := parseVersionFile(e.FilePath)
if err != nil || vf.storagePath() != e.FilePath {
continue
}
switch vf.ext {
case "mod":
versions = append(versions, vf.version)
case "info":
infos[vf.version] = true
}
}
semver.Sort(versions)
return versions, infos, nil
}
// Latest is the highest release in sorted vs, else the highest prerelease.
func Latest(vs []string) string {
for i := len(vs) - 1; i >= 0; i-- {
if semver.Prerelease(vs[i]) == "" {
return vs[i]
}
}
if len(vs) == 0 {
return ""
}
return vs[len(vs)-1]
}
func infoJSON(version string) []byte {
b, _ := json.Marshal(map[string]string{"Version": version})
return b
}
func (p *Provider) ServeLocalIndex(w http.ResponseWriter, r *http.Request, files provider.FileStore, repoName, path string) bool {
path = strings.TrimPrefix(path, "/")
switch {
case strings.HasSuffix(path, "/@v/list"), strings.HasSuffix(path, "/@latest"):
case strings.HasSuffix(path, ".info"):
vf, err := parseVersionFile(path)
if err != nil {
return false
}
escMod, _ := module.EscapePath(vf.module)
vs, infos, err := versions(r.Context(), files, repoName, escMod)
if err != nil || infos[vf.version] || !slices.Contains(vs, vf.version) {
return false
}
default:
return false
}
body, err := p.GenerateLocalIndex(r.Context(), files, repoName, path)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return true
}
w.Header().Set("Content-Type", p.ContentType(path))
w.WriteHeader(http.StatusOK)
_, _ = w.Write(body)
return true
}
func (p *Provider) GenerateLocalIndex(ctx context.Context, files provider.FileStore, repoName, path string) ([]byte, error) {
path = strings.TrimPrefix(path, "/")
if escMod, ok := strings.CutSuffix(path, "/@v/list"); ok {
vs, _, err := versions(ctx, files, repoName, escMod)
if err != nil {
return nil, err
}
var b strings.Builder
for _, v := range vs {
b.WriteString(v + "\n")
}
return []byte(b.String()), nil
}
if escMod, ok := strings.CutSuffix(path, "/@latest"); ok {
vs, _, err := versions(ctx, files, repoName, escMod)
if err != nil {
return nil, err
}
v := Latest(vs)
if v == "" {
return nil, fmt.Errorf("no versions of %s", escMod)
}
return infoJSON(v), nil
}
if strings.HasSuffix(path, ".info") {
vf, err := parseVersionFile(path)
if err != nil {
return nil, err
}
return infoJSON(vf.version), nil
}
return nil, fmt.Errorf("unsupported index path: %q", path)
}
+198
View File
@@ -0,0 +1,198 @@
package goproxy
import (
"archive/zip"
"context"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"golang.org/x/mod/module"
modzip "golang.org/x/mod/zip"
"git.unkin.net/unkin/artifactapi/internal/provider"
)
type fakeFileStore struct{ paths []string }
func (f *fakeFileStore) ListPackages(context.Context, string) ([]string, error) { return nil, nil }
func (f *fakeFileStore) ListFilesByPrefix(_ context.Context, _, prefix string) ([]provider.FileEntry, error) {
var out []provider.FileEntry
for _, p := range f.paths {
if strings.HasPrefix(p, prefix) {
out = append(out, provider.FileEntry{FilePath: p, ContentHash: "sha256:x"})
}
}
return out, nil
}
func TestValidateUpload(t *testing.T) {
p := &Provider{}
cases := []struct {
in, want string
ok bool
}{
{"example.unkin.net/hello/@v/v0.1.0.zip", "example.unkin.net/hello/@v/v0.1.0.zip", true},
{"/example.unkin.net/hello/@v/v0.1.0.mod", "example.unkin.net/hello/@v/v0.1.0.mod", true},
{"github.com/!burnt!sushi/toml/@v/v1.2.0.info", "github.com/!burnt!sushi/toml/@v/v1.2.0.info", true},
{"example.unkin.net/hello/v2/@v/v2.0.0-rc.1.zip", "example.unkin.net/hello/v2/@v/v2.0.0-rc.1.zip", true},
{"github.com/BurntSushi/toml/@v/v1.2.0.zip", "", false},
{"example.unkin.net/hello/@v/v0.1.zip", "", false},
{"example.unkin.net/hello/@v/v2.0.0.zip", "", false},
{"example.unkin.net/hello/@v/v0.1.0.tar.gz", "", false},
{"example.unkin.net/hello/v0.1.0.zip", "", false},
{"example.unkin.net/hello/@v/list", "", false},
}
for _, c := range cases {
got, ct, err := p.ValidateUpload(c.in)
if (err == nil) != c.ok {
t.Errorf("%s: err=%v, want ok=%v", c.in, err, c.ok)
continue
}
if c.ok && (got != c.want || ct == "") {
t.Errorf("%s: got %q %q, want %q", c.in, got, ct, c.want)
}
}
}
func writeZip(t *testing.T, files map[string]string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "m.zip")
f, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
zw := zip.NewWriter(f)
for name, body := range files {
w, err := zw.Create(name)
if err != nil {
t.Fatal(err)
}
if _, err := w.Write([]byte(body)); err != nil {
t.Fatal(err)
}
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
if err := f.Close(); err != nil {
t.Fatal(err)
}
return path
}
func writeFile(t *testing.T, body string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "f")
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
return path
}
func TestValidateContentZip(t *testing.T) {
p := &Provider{}
sp := "example.unkin.net/hello/@v/v0.1.0.zip"
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module example.unkin.net/hello\n"), 0o600)
os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\n"), 0o600)
good := filepath.Join(t.TempDir(), "good.zip")
f, _ := os.Create(good)
if err := modzip.CreateFromDir(f, module.Version{Path: "example.unkin.net/hello", Version: "v0.1.0"}, dir); err != nil {
t.Fatal(err)
}
f.Close()
if err := p.ValidateContent(sp, good); err != nil {
t.Errorf("valid zip rejected: %v", err)
}
bad := map[string]map[string]string{
"no prefix": {"go.mod": "module example.unkin.net/hello\n"},
"wrong version": {"example.unkin.net/hello@v0.2.0/go.mod": "module example.unkin.net/hello\n"},
"wrong module": {"example.unkin.net/other@v0.1.0/go.mod": "module example.unkin.net/other\n"},
}
for name, files := range bad {
if err := p.ValidateContent(sp, writeZip(t, files)); err == nil {
t.Errorf("%s: expected rejection", name)
}
}
if err := p.ValidateContent(sp, writeFile(t, "not a zip")); err == nil {
t.Error("non-zip accepted")
}
}
func TestValidateContentModInfo(t *testing.T) {
p := &Provider{}
mod := "example.unkin.net/hello/@v/v0.1.0.mod"
info := "example.unkin.net/hello/@v/v0.1.0.info"
cases := []struct {
path, body string
ok bool
}{
{mod, "module example.unkin.net/hello\n\ngo 1.25\n", true},
{mod, "module example.unkin.net/other\n", false},
{mod, "go 1.25\n", false},
{mod, "module (\n", false},
{info, `{"Version":"v0.1.0","Time":"2026-01-01T00:00:00Z"}`, true},
{info, `{"Version":"v0.2.0"}`, false},
{info, `nope`, false},
}
for _, c := range cases {
if err := p.ValidateContent(c.path, writeFile(t, c.body)); (err == nil) != c.ok {
t.Errorf("%s %q: err=%v, want ok=%v", c.path, c.body, err, c.ok)
}
}
}
func serve(t *testing.T, fs provider.FileStore, path string) (bool, int, string) {
t.Helper()
rec := httptest.NewRecorder()
handled := (&Provider{}).ServeLocalIndex(rec, httptest.NewRequest("GET", "/"+path, nil), fs, "go-local", path)
return handled, rec.Code, rec.Body.String()
}
func TestServeLocalIndex(t *testing.T) {
fs := &fakeFileStore{paths: []string{
"example.unkin.net/hello/@v/v0.10.0.mod",
"example.unkin.net/hello/@v/v0.10.0.zip",
"example.unkin.net/hello/@v/v0.2.0.mod",
"example.unkin.net/hello/@v/v0.2.0.zip",
"example.unkin.net/hello/@v/v0.2.0.info",
"example.unkin.net/hello/@v/v0.11.0-rc.1.mod",
"example.unkin.net/hello/@v/v0.3.0.zip",
"example.unkin.net/hello/v2/@v/v2.0.0.mod",
"example.unkin.net/!pre/@v/v1.0.0-beta.mod",
}}
if h, code, body := serve(t, fs, "example.unkin.net/hello/@v/list"); !h || code != 200 || body != "v0.2.0\nv0.10.0\nv0.11.0-rc.1\n" {
t.Errorf("list: %v %d %q", h, code, body)
}
if h, code, body := serve(t, fs, "example.unkin.net/hello/@latest"); !h || code != 200 || body != `{"Version":"v0.10.0"}` {
t.Errorf("latest: %v %d %q", h, code, body)
}
if h, _, body := serve(t, fs, "example.unkin.net/!pre/@latest"); !h || body != `{"Version":"v1.0.0-beta"}` {
t.Errorf("prerelease-only latest: %q", body)
}
if h, code, _ := serve(t, fs, "example.unkin.net/missing/@latest"); !h || code != 404 {
t.Errorf("missing latest: %v %d", h, code)
}
if h, code, body := serve(t, fs, "example.unkin.net/missing/@v/list"); !h || code != 200 || body != "" {
t.Errorf("missing list: %v %d %q", h, code, body)
}
if h, _, body := serve(t, fs, "example.unkin.net/hello/@v/v0.10.0.info"); !h || body != `{"Version":"v0.10.0"}` {
t.Errorf("synthesized info: %v %q", h, body)
}
if h, _, _ := serve(t, fs, "example.unkin.net/hello/@v/v0.2.0.info"); h {
t.Error("uploaded .info must be served from storage")
}
if h, _, _ := serve(t, fs, "example.unkin.net/hello/@v/v0.3.0.info"); h {
t.Error(".info without a .mod must not be synthesized")
}
if h, _, _ := serve(t, fs, "example.unkin.net/hello/@v/v0.10.0.zip"); h {
t.Error(".zip must be served from storage")
}
}
+3
View File
@@ -8,6 +8,9 @@ import (
"git.unkin.net/unkin/artifactapi/pkg/models"
)
// RemoteMountPath is the URL path remote proxies are served under.
const RemoteMountPath = "/api/v1/remote/"
type Mutability int
const (
+43 -33
View File
@@ -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, "-")
+2 -2
View File
@@ -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")
}
}
+27
View File
@@ -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) {
+25
View File
@@ -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)
}
}
}
+141 -12
View File
@@ -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
@@ -343,6 +471,9 @@ func (p *GitHubProvider) scanWithState(ctx context.Context, remote models.Remote
return newEtag, false, err
}
// A transiently failed asset fails the scan so the ETag is not advanced and
// the retry re-derives it; an invalid package is skipped as no retry fixes it.
var failed []error
seen := map[string]bool{}
for _, rel := range releases {
if rel.Draft {
@@ -373,10 +504,14 @@ func (p *GitHubProvider) scanWithState(ctx context.Context, remote models.Remote
meta, err := p.deriveAsset(ctx, remote, asset, fp)
if err != nil {
slog.Warn("github_rpm: derive asset failed", "remote", remote.Name, "asset", asset.Name, "error", err)
if !errors.Is(err, provider.ErrInvalidPackage) {
failed = append(failed, fmt.Errorf("%s: %w", asset.Name, err))
}
continue
}
if err := store.InsertRPMMetadata(ctx, meta); err != nil {
slog.Error("github_rpm: insert metadata failed", "remote", remote.Name, "asset", asset.Name, "error", err)
failed = append(failed, fmt.Errorf("%s: %w", asset.Name, err))
continue
}
slog.Info("github_rpm: derived asset", "remote", remote.Name, "name", meta.Name, "version", meta.Version, "arch", meta.Arch)
@@ -388,7 +523,7 @@ func (p *GitHubProvider) scanWithState(ctx context.Context, remote models.Remote
_ = store.DeleteRPMMetadata(ctx, remote.Name, fp)
}
}
return newEtag, true, nil
return newEtag, true, errors.Join(failed...)
}
type ghRelease struct {
@@ -569,12 +704,12 @@ func (p *GitHubProvider) fetchHeader(ctx context.Context, remote models.Remote,
}
continue
}
return nil, fmt.Errorf("parse rpm header: %w", perr)
return nil, fmt.Errorf("%w: parse rpm header: %w", provider.ErrInvalidPackage, perr)
}
}
// rangeGet returns the first n bytes of downloadURL. full is true when the
// response body was shorter than n (i.e. we already have the whole object).
// response holds the whole object.
func (p *GitHubProvider) rangeGet(ctx context.Context, remote models.Remote, downloadURL string, n int64) ([]byte, bool, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
if err != nil {
@@ -596,15 +731,9 @@ func (p *GitHubProvider) rangeGet(ctx context.Context, remote models.Remote, dow
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {
return nil, false, fmt.Errorf("range GET %s: status %d", downloadURL, resp.StatusCode)
return nil, false, fmt.Errorf("range GET: %w", provider.AssetStatusError(downloadURL, resp))
}
body, err := io.ReadAll(io.LimitReader(resp.Body, n))
if err != nil {
return nil, false, err
}
full := int64(len(body)) < n
return body, full, nil
return provider.ReadPrefix(resp, n)
}
func (p *GitHubProvider) computeSHA256(ctx context.Context, remote models.Remote, downloadURL string) (string, error) {
@@ -627,7 +756,7 @@ func (p *GitHubProvider) computeSHA256(ctx context.Context, remote models.Remote
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("GET %s: status %d", downloadURL, resp.StatusCode)
return "", fmt.Errorf("GET: %w", provider.AssetStatusError(downloadURL, resp))
}
h := sha256.New()
@@ -0,0 +1,347 @@
package rpm
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"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")
}
}
// waitFor polls until cond holds, then gives goroutines that passed the
// fingerprint a moment to reach the singleflight call.
func waitFor(t *testing.T, cond func() bool) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for !cond() {
if time.Now().After(deadline) {
t.Fatal("condition not reached")
}
time.Sleep(time.Millisecond)
}
time.Sleep(50 * time.Millisecond)
}
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.fpCount() == n })
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 store.fpCount() == 2 })
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())
}
}
+34 -6
View File
@@ -1,11 +1,13 @@
package rpm
import (
"cmp"
"compress/gzip"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -24,15 +26,22 @@ import (
// fakeStore is an in-memory provider.RemoteMetadataStore keyed by file_path,
// mirroring the (repo_name, file_path) uniqueness of the real table.
type fakeStore struct {
mu sync.Mutex
rows map[string]provider.RPMMetadata
mu sync.Mutex
rows map[string]provider.RPMMetadata
insertFail map[string]int // package name -> inserts left to fail
}
func newFakeStore() *fakeStore { return &fakeStore{rows: map[string]provider.RPMMetadata{}} }
func newFakeStore() *fakeStore {
return &fakeStore{rows: map[string]provider.RPMMetadata{}, insertFail: map[string]int{}}
}
func (f *fakeStore) InsertRPMMetadata(_ context.Context, m *provider.RPMMetadata) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.insertFail[m.Name] > 0 {
f.insertFail[m.Name]--
return errors.New("insert failed")
}
if _, ok := f.rows[m.FilePath]; ok {
return nil // ON CONFLICT DO NOTHING
}
@@ -79,15 +88,20 @@ type githubFixture struct {
releaseAuth string // Authorization header seen on the last releases request
assetAuth string // Authorization header seen on the last asset request
failStatus int // when set, the releases list answers this status (rate-limit style)
assetFail map[string]int // asset filename -> downloads left to answer failCode
failCode int // status for assetFail downloads; 0 = 500
shortRead map[string]int // asset filename -> ranged GETs left to close early
mu sync.Mutex
}
func newGitHubFixture(t *testing.T, withDigest bool) *githubFixture {
t.Helper()
f := &githubFixture{
rpmBytes: map[string][]byte{},
rangeHit: map[string]int{},
fullHit: map[string]int{},
rpmBytes: map[string][]byte{},
rangeHit: map[string]int{},
fullHit: map[string]int{},
assetFail: map[string]int{},
shortRead: map[string]int{},
}
f.rpmBytes["demo-1.2-3.x86_64.rpm"] = testsupport.MinimalRPM("demo", "1.2", "3", "x86_64")
@@ -145,6 +159,17 @@ func newGitHubFixture(t *testing.T, withDigest bool) *githubFixture {
}
rng := r.Header.Get("Range")
f.mu.Lock()
if f.assetFail[name] > 0 {
f.assetFail[name]--
code := cmp.Or(f.failCode, http.StatusInternalServerError)
f.mu.Unlock()
http.Error(w, "boom", code)
return
}
short := rng != "" && f.shortRead[name] > 0
if short {
f.shortRead[name]--
}
f.assetAuth = r.Header.Get("Authorization")
if rng != "" {
f.rangeHit[name]++
@@ -165,6 +190,9 @@ func newGitHubFixture(t *testing.T, withDigest bool) *githubFixture {
end = len(body) - 1
}
w.Header().Set("Content-Range", fmt.Sprintf("bytes 0-%d/%d", end, len(body)))
if short {
end /= 2
}
w.Header().Set("Content-Length", strconv.Itoa(end+1))
w.WriteHeader(http.StatusPartialContent)
w.Write(body[:end+1])
+33 -17
View File
@@ -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) {

Some files were not shown because too many files have changed in this diff Show More