Compare commits

...

19 Commits

Author SHA1 Message Date
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 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 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
133 changed files with 9219 additions and 313 deletions
+10 -6
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. 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). 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>/`: 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) - `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) - `docker`: [remote](docs/docker/remote.md), [local](docs/docker/local.md)
- `generic`: [remote](docs/generic/remote.md) - `generic`: [remote](docs/generic/remote.md)
- `gems`: [remote](docs/gems/remote.md), [local](docs/gems/local.md), [virtual](docs/gems/virtual.md) - `gems`: [remote](docs/gems/remote.md), [local](docs/gems/local.md), [virtual](docs/gems/virtual.md)
- `goproxy`: [remote](docs/goproxy/remote.md) - `goproxy`: [remote](docs/goproxy/remote.md), [local](docs/goproxy/local.md), [virtual](docs/goproxy/virtual.md)
- `helm`: [remote](docs/helm/remote.md), [local](docs/helm/local.md), [virtual](docs/helm/virtual.md) - `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) - `npm`: [remote](docs/npm/remote.md), [local](docs/npm/local.md), [virtual](docs/npm/virtual.md)
- `nuget`: [remote](docs/nuget/remote.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) - `pypi`: [remote](docs/pypi/remote.md)
- `rpm`: [remote](docs/rpm/remote.md), [remote-github](docs/rpm/remote-github.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) - `terraform`: [remote](docs/terraform/remote.md), [local](docs/terraform/local.md)
- `vcs`: [remote](docs/vcs/remote.md)
### GitHub authentication ### GitHub authentication
@@ -200,14 +203,15 @@ additive and idempotent for the same reason; a test enforces it.
| `GITHUB_SYNC_RATE` | `1` | `github_rpm` syncer global GitHub request rate (req/s), shared across all remotes. `1`/s = 3600/hr, under an authenticated token's ~5000/hr; unauthenticated (~60/hr) relies on ETag/304 | | `GITHUB_SYNC_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_BURST` | `5` | Token-bucket burst for the shared limiter |
| `GITHUB_SYNC_WORKERS` | `3` | Concurrent `github_rpm` scan workers | | `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_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_ID` | | GitHub App id (App auth mode; mutually exclusive with `GITHUB_TOKEN`) |
| `GITHUB_APP_INSTALLATION_ID` | | GitHub App installation id | | `GITHUB_APP_INSTALLATION_ID` | | GitHub App installation id |
| `GITHUB_APP_PRIVATE_KEY` | | GitHub App private key, inline PEM | | `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) | | `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_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 ## 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. | | `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. | | `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. | | `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. | | `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 ## 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 Concurrent misses on one path share a fetch lock. Waiters poll the store for
up to 5s instead of all going upstream. 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 ## Eviction
```sh ```sh
@@ -93,9 +112,9 @@ Force a moving path mutable, e.g. branch tarballs on `generic`:
## Built-in classification per type ## Built-in classification per type
Each type's page lists its auto-detected mutable and immutable paths: 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), [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), [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), [helm](../helm/remote.md), [npm](../npm/remote.md), [nuget](../nuget/remote.md),
[puppet](../puppet/remote.md), [pypi](../pypi/remote.md), [rpm](../rpm/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 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 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 is the image name. Blobs and manifests are stored through the shared
content-addressable store (deduplicated by digest, reaped by GC once content-addressable store (deduplicated by digest, reaped by GC once
+1 -1
View File
@@ -2,7 +2,7 @@
A local `gems` repo is a RubyGems source: `gem push`, `gem yank`, `gem install` 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 and `bundle install` work against `/api/v1/local/<repo>`. `gem push` needs an
API key; any value works. API key; any value works unless [uploads are gated](../common/auth.md).
```bash ```bash
GEM_HOST_API_KEY=unused gem push --host https://artifactapi.example.com/api/v1/local/<repo> mygem-1.0.0.gem GEM_HOST_API_KEY=unused gem push --host https://artifactapi.example.com/api/v1/local/<repo> mygem-1.0.0.gem
+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) 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 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 ```ini
# .npmrc # .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) | | Type | Mutable (auto-detected) | Immutable (auto-detected) |
|---|---|---| |---|---|---|
| `puppet` | `v3/modules/*`, `v3/releases*` | `.tar.gz` | | `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.
+7 -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 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 prime lands, it returns a retryable `503` (with `Retry-After`) rather than
serving an empty repo or blocking on a multi-minute derive. serving an empty repo or blocking on a multi-minute derive.
- **Periodic re-check, driven by `mutable_ttl`.** Each remote is re-checked for - **Periodic re-check, driven by `sync_interval` or `mutable_ttl`.** With
new or changed releases no more often than its `mutable_ttl`. New/changed `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, assets are derived incrementally; assets already cached are never re-fetched,
and assets that disappear upstream are pruned. and assets that disappear upstream are pruned.
- **ETag / 304 conditional requests.** The releases-list `ETag` is stored per - **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 periodic scan a replica must atomically claim a per-remote lease
(`github_rpm_sync_state`: `last_synced_at`, `etag`, `sync_lease_owner`, (`github_rpm_sync_state`: `last_synced_at`, `etag`, `sync_lease_owner`,
`sync_lease_expires`); only the winner scans. This bounds total GitHub load to `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. lets any replica issue the conditional request.
```hcl ```hcl
+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
```
+8 -2
View File
@@ -31,12 +31,18 @@ already-running stack.
`node:22` client: `npm publish` unscoped + scoped, `npm install` + `require()`, `node:22` client: `npm publish` unscoped + scoped, `npm install` + `require()`,
second version, republish rejected), gems (stock `ruby:3.3`: `gem push`, second version, republish rejected), gems (stock `ruby:3.3`: `gem push`,
`gem install`, `bundle install` + `require`, republish rejected, `gem yank`, `gem install`, `bundle install` + `require`, republish rejected, `gem yank`,
yanked re-push rejected). yanked re-push rejected), ansible (stock `ansible-galaxy`: `collection build`,
`publish`, republish rejected, `install` + module run).
- **Virtual repositories** — pypi simple-index merge and helm `index.yaml` merge - **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` across two members; npm packument merge over two locals via a stock `node:22`
client (merged versions, install through the member redirect); gems compact client (merged versions, install through the member redirect); gems compact
index merge over two locals via stock `ruby:3.3` bundler (member order wins, index merge over two locals via stock `ruby:3.3` bundler (member order wins,
downloads 302 to the owning member). downloads 302 to the owning member); ansible over two locals via stock
`ansible-galaxy` (install through the virtual, download 302 to the owner);
goproxy over two locals via a stock `golang:1.25` client (merged versions,
member order wins, version files 302 to the owning member); puppet over two
locals via real `puppet module install` (dependency resolved across members)
and `r10k`, downloads 302 to the owning member.
- **Mirrorlist** — an rpm remote with a `mirrorlist` of extra upstream mirrors - **Mirrorlist** — an rpm remote with a `mirrorlist` of extra upstream mirrors
(pool = `base_url` + `mirrorlist`): round-robin distribution across both mirrors (pool = `base_url` + `mirrorlist`): round-robin distribution across both mirrors
(constant-body `mockupstreama` / `mockupstreamb`), failover past a dead primary, (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)
}
}
+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") 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") 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. // The index is served (empty) at once but lists the package only once the
resp, body := getEventually(t, api("/api/v1/local/local-alpine/x86_64/APKINDEX.tar.gz"), 15*time.Second) // upload's metadata is parsed asynchronously; poll until it does.
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))
var index string var index string
for { for deadline := time.Now().Add(15 * time.Second); !strings.Contains(index, "P:e2e-testpkg") && time.Now().Before(deadline); time.Sleep(250 * time.Millisecond) {
hdr, err := tr.Next() resp, body := doRequest(t, http.MethodGet, api("/api/v1/local/local-alpine/x86_64/APKINDEX.tar.gz"), nil, "")
if err == io.EOF { if resp.StatusCode != http.StatusOK {
break t.Fatalf("APKINDEX: status %d: %s", resp.StatusCode, body)
}
if err != nil {
t.Fatalf("APKINDEX not tar: %v", err)
}
if hdr.Name == "APKINDEX" {
b, _ := io.ReadAll(tr)
index = string(b)
} }
index = apkIndexText(t, body)
} }
for _, want := range []string{"P:e2e-testpkg", "V:1.0-r0", "A:x86_64", "C:Q1", "S:", "I:"} { for _, want := range []string{"P:e2e-testpkg", "V:1.0-r0", "A:x86_64", "C:Q1", "S:", "I:"} {
if !strings.Contains(index, want) { if !strings.Contains(index, want) {
@@ -183,7 +167,7 @@ func TestLocalAlpineIndex(t *testing.T) {
} }
// The .apk downloads back byte-identical from its arch path. // 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 { if resp.StatusCode != http.StatusOK {
t.Fatalf("download apk: status %d: %s", resp.StatusCode, body) t.Fatalf("download apk: status %d: %s", resp.StatusCode, body)
} }
@@ -191,3 +175,25 @@ func TestLocalAlpineIndex(t *testing.T) {
t.Fatalf("apk content mismatch") 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"}]}` 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 { 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() t.Helper()
var buf bytes.Buffer var buf bytes.Buffer
zw := gzip.NewWriter(&buf) zw := gzip.NewWriter(&buf)
tw := tar.NewWriter(zw) tw := tar.NewWriter(zw)
for name, body := range map[string]string{ for name, body := range map[string]string{
"unkin-hello-0.1.0/metadata.json": puppetHelloMetadata, dir + "/metadata.json": metadata,
"unkin-hello-0.1.0/manifests/init.pp": "class hello {}\n", 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 { if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: int64(len(body)), ModTime: time.Now()}); err != nil {
t.Fatal(err) 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/testcontainers/testcontainers-go/modules/redis v0.42.0
github.com/ulikunitz/xz v0.5.16 github.com/ulikunitz/xz v0.5.16
golang.org/x/crypto v0.54.0 golang.org/x/crypto v0.54.0
golang.org/x/mod v0.37.0
golang.org/x/sync v0.22.0 golang.org/x/sync v0.22.0
golang.org/x/time v0.15.0 golang.org/x/time v0.15.0
gopkg.in/yaml.v3 v3.0.1 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/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 h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= 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 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= 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/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 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= 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 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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+289
View File
@@ -0,0 +1,289 @@
package v1
import (
"bytes"
"cmp"
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"slices"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"git.unkin.net/unkin/artifactapi/internal/database"
"git.unkin.net/unkin/artifactapi/internal/provider/ansible"
"git.unkin.net/unkin/artifactapi/internal/virtual"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
var ansibleMaxPublishBytes int64 = 256 << 20
// ansibleError writes a Galaxy v3 error document, which ansible-galaxy prints.
func ansibleError(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]any{"errors": []map[string]string{{
"status": strconv.Itoa(status), "code": strings.ToLower(strings.ReplaceAll(http.StatusText(status), " ", "_")),
"title": http.StatusText(status), "detail": msg,
}}})
}
func writeAnsibleJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Vary", "Host, X-Forwarded-Proto")
w.WriteHeader(status)
enc := json.NewEncoder(w)
enc.SetEscapeHTML(false)
_ = enc.Encode(v)
}
func isAnsiblePublish(path string) bool {
return strings.TrimSuffix(path, "/")+"/" == ansible.PublishPath
}
func (h *ProxyHandler) handleAnsiblePublish(w http.ResponseWriter, r *http.Request) {
remote, err := h.db.GetRemote(r.Context(), chi.URLParam(r, "localName"))
if err != nil || remote.RepoType != models.RepoTypeLocal || remote.PackageType != models.PackageAnsible {
ansibleError(w, http.StatusMethodNotAllowed, "publish is only supported on local ansible repositories")
return
}
base, ok := requestBaseURL(w, r)
if !ok {
return
}
r.Body = http.MaxBytesReader(w, r.Body, ansibleMaxPublishBytes)
file, sum, err := readAnsibleUpload(r)
if err != nil {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
ansibleError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("upload exceeds %d bytes", ansibleMaxPublishBytes))
return
}
ansibleError(w, http.StatusBadRequest, err.Error())
return
}
digest := sha256.Sum256(file)
if got := hex.EncodeToString(digest[:]); sum != "" && !strings.EqualFold(sum, got) {
ansibleError(w, http.StatusBadRequest, "sha256 does not match the uploaded file")
return
}
e, err := ansible.ParseCollection(bytes.NewReader(file))
if err != nil {
ansibleError(w, http.StatusBadRequest, err.Error())
return
}
e.SHA256, e.Size, e.CreatedAt = hex.EncodeToString(digest[:]), int64(len(file)), time.Now().UTC()
id := e.Namespace + "." + e.Name + ":" + e.Version
unlock, err := h.engine.Lock(r.Context(), remote.Name, "_ansiblelock/"+e.TaskID(), gemsLockWait, 5*time.Minute)
if err != nil {
ansibleError(w, http.StatusServiceUnavailable, id+" is being published, retry")
return
}
defer unlock()
conflict := func() {
ansibleError(w, http.StatusConflict, id+" already exists; overwrites are not allowed")
}
if f, err := h.db.GetLocalFile(r.Context(), remote.Name, e.IndexPath()); err != nil {
ansibleError(w, http.StatusInternalServerError, err.Error())
return
} else if f != nil {
conflict()
return
}
// The version is not live, so a tarball left by a failed publish is safe to drop.
artifact := ansible.ArtifactPrefix + e.Filename()
fail := func(status int, msg string) {
if err := h.db.DeleteLocalFile(context.WithoutCancel(r.Context()), remote.Name, artifact); err != nil {
slog.Error("ansible publish cleanup failed", "repo", remote.Name, "path", artifact, "error", err)
}
ansibleError(w, status, msg)
}
if ok, err := h.npmStoreImmutable(r.Context(), remote.Name, artifact, file, "application/gzip"); err != nil {
fail(http.StatusInternalServerError, err.Error())
return
} else if !ok {
fail(http.StatusConflict, id+" already exists; overwrites are not allowed")
return
}
entry, err := json.Marshal(e)
if err != nil {
fail(http.StatusInternalServerError, err.Error())
return
}
// The index entry gates visibility, so it is written last.
if _, err := h.npmStore(r.Context(), remote.Name, e.IndexPath(), entry, "application/json", false); err != nil {
if errors.Is(err, database.ErrAlreadyExists) {
conflict()
return
}
fail(http.StatusInternalServerError, err.Error())
return
}
slog.Info("local ansible collection published", "repo", remote.Name, "collection", id)
repoBase := base + "/api/v1/local/" + url.PathEscape(remote.Name)
writeAnsibleJSON(w, http.StatusAccepted, map[string]string{"task": repoBase + "/api/v3/imports/collections/" + e.TaskID() + "/"})
}
// readAnsibleUpload returns the multipart "file" part, which ansible-galaxy
// base64-encodes, and the optional "sha256" field sent with it.
func readAnsibleUpload(r *http.Request) ([]byte, string, error) {
mr, err := r.MultipartReader()
if err != nil {
return nil, "", errors.New("expected a multipart/form-data upload")
}
var file []byte
var sum string
for {
part, err := mr.NextPart()
if err == io.EOF {
break
}
if err != nil {
return nil, "", err
}
switch part.FormName() {
case "file":
if file != nil {
return nil, "", errors.New("multiple file parts")
}
var src io.Reader = part
switch enc := strings.ToLower(part.Header.Get("Content-Transfer-Encoding")); enc {
case "base64":
src = base64.NewDecoder(base64.StdEncoding, part)
case "", "binary", "7bit", "8bit":
default:
return nil, "", fmt.Errorf("unsupported Content-Transfer-Encoding %q", enc)
}
if file, err = io.ReadAll(src); err != nil {
return nil, "", err
}
case "sha256":
b, err := io.ReadAll(io.LimitReader(part, 128))
if err != nil {
return nil, "", err
}
sum = strings.TrimSpace(string(b))
}
}
if file == nil {
return nil, "", errors.New("missing file part")
}
return file, sum, nil
}
// serveLocalAnsible answers the Galaxy v3 API of a local ansible repo.
func (h *ProxyHandler) serveLocalAnsible(w http.ResponseWriter, r *http.Request, remote *models.Remote, ap ansible.APIPath) {
base, ok := requestBaseURL(w, r)
if !ok {
return
}
body, err := h.localAnsibleDoc(r.Context(), remote.Name, ap, r.URL.Query(), base+"/api/v1/local/"+url.PathEscape(remote.Name))
if errors.Is(err, virtual.ErrNotFound) {
ansibleError(w, http.StatusNotFound, "not found")
return
}
if err != nil {
ansibleError(w, http.StatusInternalServerError, err.Error())
return
}
w.Header().Set("X-Artifact-Source", "local")
writeAnsibleJSON(w, http.StatusOK, body)
}
// LocalAnsible serves a local ansible repo's collection, full versions list
// or version detail to ansible virtuals, with root-relative URLs.
func (h *ProxyHandler) LocalAnsible(ctx context.Context, repo, path string) ([]byte, error) {
doc, err := h.localAnsibleDoc(ctx, repo, ansible.ParseAPIPath(path), nil, "/api/v1/local/"+url.PathEscape(repo))
if err != nil {
return nil, err
}
return json.Marshal(doc)
}
// localAnsibleDoc builds the document for ap; a nil q lists every version.
func (h *ProxyHandler) localAnsibleDoc(ctx context.Context, repo string, ap ansible.APIPath, q url.Values, base string) (any, error) {
switch ap.Kind {
case ansible.APIRoot:
return ansible.Root(), nil
case ansible.APIImport:
ns, rest, _ := strings.Cut(ap.TaskID, "-")
name, version, _ := strings.Cut(rest, "-")
entries, err := h.localAnsibleEntries(ctx, repo, ns, name)
if err != nil {
return nil, err
}
for _, e := range entries {
if e.Version == version {
return ansible.ImportTask(e), nil
}
}
case ansible.APICollection, ansible.APIVersions, ansible.APIVersion:
entries, err := h.localAnsibleEntries(ctx, repo, ap.Namespace, ap.Name)
if err != nil || len(entries) == 0 {
return nil, cmp.Or(err, virtual.ErrNotFound)
}
refs := make([]ansible.VersionRef, len(entries))
for i, e := range entries {
refs[i] = e.Ref()
}
switch ap.Kind {
case ansible.APICollection:
return ansible.CollectionDoc(base, ap.Namespace, ap.Name, refs), nil
case ansible.APIVersions:
limit, offset := len(refs), 0
if q != nil {
limit, offset = ansible.Page(q)
}
return ansible.VersionsPage(base, ap.Namespace, ap.Name, refs, limit, offset), nil
}
for _, e := range entries {
if e.Version == ap.Version {
return ansible.VersionDoc(base, e), nil
}
}
}
return nil, virtual.ErrNotFound
}
// localAnsibleEntries returns a collection's versions, newest publish first.
func (h *ProxyHandler) localAnsibleEntries(ctx context.Context, repo, ns, name string) ([]ansible.Entry, error) {
if !ansible.NameRe.MatchString(ns) || !ansible.NameRe.MatchString(name) {
return nil, nil
}
prefix := ansible.IndexPrefix + ns + "/" + name + "/"
files, err := h.db.ListLocalFilesByPrefix(ctx, repo, prefix)
if err != nil {
return nil, err
}
slices.SortFunc(files, func(a, b database.LocalFile) int { return cmp.Compare(b.ID, a.ID) })
var out []ansible.Entry
for _, f := range files {
// LIKE treats '_' as a wildcard, so the prefix match can over-select.
if !strings.HasPrefix(f.FilePath, prefix) {
continue
}
raw, err := h.readLocalBlob(ctx, f.ContentHash)
if err != nil {
return nil, err
}
var e ansible.Entry
if err := json.Unmarshal(raw, &e); err != nil {
return nil, fmt.Errorf("decode %s: %w", f.FilePath, err)
}
out = append(out, e)
}
return out, nil
}
+275
View File
@@ -0,0 +1,275 @@
package v1
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"encoding/base64"
"encoding/json"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/textproto"
"strings"
"testing"
"git.unkin.net/unkin/artifactapi/internal/virtual"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
func testCollection(t *testing.T, ns, name, version string) []byte {
t.Helper()
manifest := `{"collection_info":{"namespace":"` + ns + `","name":"` + name + `","version":"` + version + `","dependencies":{"ansible.posix":">=1.0.0"}}}`
var buf bytes.Buffer
zw := gzip.NewWriter(&buf)
tw := tar.NewWriter(zw)
_ = tw.WriteHeader(&tar.Header{Name: "MANIFEST.json", Mode: 0o644, Size: int64(len(manifest))})
_, _ = tw.Write([]byte(manifest))
_ = tw.Close()
_ = zw.Close()
return buf.Bytes()
}
// ansiblePublish uploads like ansible-galaxy: the file part base64-encoded
// whenever a sha256 field is sent, raw otherwise.
func ansiblePublish(t *testing.T, url string, tarball []byte, sum string) (int, string) {
t.Helper()
var body bytes.Buffer
mw := multipart.NewWriter(&body)
hdr := textproto.MIMEHeader{"Content-Disposition": {`form-data; name="file"; filename="c.tar.gz"`}}
data := tarball
if sum != "" {
_ = mw.WriteField("sha256", sum)
hdr.Set("Content-Transfer-Encoding", "base64")
data = []byte(base64.StdEncoding.EncodeToString(tarball))
}
fw, _ := mw.CreatePart(hdr)
_, _ = fw.Write(data)
_ = mw.Close()
req, _ := http.NewRequest(http.MethodPost, url, &body)
req.Header.Set("Content-Type", mw.FormDataContentType())
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
var b bytes.Buffer
_, _ = b.ReadFrom(resp.Body)
return resp.StatusCode, b.String()
}
func getJSON(t *testing.T, url string, v any) {
t.Helper()
code, body := npmDo(t, http.MethodGet, url, "")
if code != http.StatusOK {
t.Fatalf("GET %s = %d: %s", url, code, body)
}
if err := json.Unmarshal([]byte(body), v); err != nil {
t.Fatalf("GET %s: %v: %s", url, err, body)
}
}
func TestAnsiblePublishAndServe(t *testing.T) {
h, srv := npmTestServer(t)
repo := npmRepo(t, h, models.PackageAnsible)
base := srv.URL + "/api/v1/local/" + repo + "/"
publish := base + "api/v3/artifacts/collections/"
v1 := testCollection(t, "unkin", "e2e_hello", "1.0.0")
code, body := ansiblePublish(t, publish, v1, sha256Hex(v1))
if code != http.StatusAccepted {
t.Fatalf("publish = %d: %s", code, body)
}
var task struct{ Task string }
_ = json.Unmarshal([]byte(body), &task)
if task.Task != base+"api/v3/imports/collections/unkin-e2e_hello-1.0.0/" {
t.Fatalf("task = %q", task.Task)
}
var st map[string]any
getJSON(t, task.Task, &st)
if st["state"] != "completed" || st["finished_at"] == "" {
t.Fatalf("task = %v", st)
}
if code, _ := npmDo(t, http.MethodGet, base+"api/v3/imports/collections/unkin-e2e_hello-9.9.9/", ""); code != http.StatusNotFound {
t.Errorf("unknown task = %d, want 404", code)
}
for _, v := range []string{"1.1.0", "2.0.0"} {
tb := testCollection(t, "unkin", "e2e_hello", v)
if code, body := ansiblePublish(t, publish, tb, ""); code != http.StatusAccepted {
t.Fatalf("publish %s = %d: %s", v, code, body)
}
}
if code, body := ansiblePublish(t, publish, v1, ""); code != http.StatusConflict || !strings.Contains(body, "already exists") {
t.Errorf("identical republish = %d: %s, want 409", code, body)
}
if code, _ := ansiblePublish(t, publish, testCollection(t, "unkin", "e2e_hello", "1.0.0")[:20], ""); code != http.StatusBadRequest {
t.Errorf("truncated tarball = %d, want 400", code)
}
var root map[string]map[string]string
getJSON(t, base+"api/", &root)
if root["available_versions"]["v3"] != "v3/" {
t.Fatalf("root = %v", root)
}
var page struct {
Meta struct{ Count int }
Links struct{ Next, Previous *string }
Data []struct{ Version, Href string }
}
getJSON(t, base+"api/v3/collections/unkin/e2e_hello/versions/?limit=2", &page)
if page.Meta.Count != 3 || len(page.Data) != 2 || page.Data[0].Version != "2.0.0" || page.Links.Next == nil || page.Links.Previous != nil {
t.Fatalf("page 1 = %+v", page)
}
next := *page.Links.Next
if !strings.HasPrefix(next, "/api/v1/local/"+repo+"/") {
t.Fatalf("next = %q, want root-relative", next)
}
next = srv.URL + next
page.Links.Next = nil
getJSON(t, next, &page)
if len(page.Data) != 1 || page.Data[0].Version != "1.0.0" || page.Links.Next != nil {
t.Fatalf("page 2 (%s) = %+v", next, page)
}
var detail struct {
DownloadURL string `json:"download_url"`
Artifact struct{ Sha256 string }
Metadata struct{ Dependencies map[string]string }
Namespace struct{ Name string }
Collection struct{ Name string }
}
getJSON(t, page.Data[0].Href, &detail)
if detail.DownloadURL != base+"artifacts/unkin-e2e_hello-1.0.0.tar.gz" || detail.Artifact.Sha256 != sha256Hex(v1) ||
detail.Metadata.Dependencies["ansible.posix"] != ">=1.0.0" || detail.Namespace.Name != "unkin" || detail.Collection.Name != "e2e_hello" {
t.Fatalf("detail = %+v", detail)
}
if code, got := npmDo(t, http.MethodGet, detail.DownloadURL, ""); code != http.StatusOK || got != string(v1) {
t.Fatalf("download = %d (%d bytes)", code, len(got))
}
var coll map[string]any
getJSON(t, base+"api/v3/collections/unkin/e2e_hello/", &coll)
if coll["versions_url"] != base+"api/v3/collections/unkin/e2e_hello/versions/" || coll["updated_at"] == "" {
t.Fatalf("collection = %v", coll)
}
for _, p := range []string{"api/v3/collections/unkin/nope/", "api/v3/collections/unkin/e2e_hello/versions/9.0.0/", "_index/unkin/e2e_hello/1.0.0", "other"} {
if code, _ := npmDo(t, http.MethodGet, base+p, ""); code != http.StatusNotFound {
t.Errorf("GET %s = %d, want 404", p, code)
}
}
}
func TestAnsiblePublishRejects(t *testing.T) {
h, srv := npmTestServer(t)
repo := npmRepo(t, h, models.PackageAnsible)
publish := srv.URL + "/api/v1/local/" + repo + "/api/v3/artifacts/collections/"
tb := testCollection(t, "unkin", "rej", "1.0.0")
if code, _ := ansiblePublish(t, publish, tb, strings.Repeat("0", 64)); code != http.StatusBadRequest {
t.Errorf("sha256 mismatch = %d, want 400", code)
}
if code, _ := npmDo(t, http.MethodPost, publish, "raw"); code != http.StatusBadRequest {
t.Errorf("non-multipart = %d, want 400", code)
}
old := ansibleMaxPublishBytes
ansibleMaxPublishBytes = 64
if code, _ := ansiblePublish(t, publish, tb, ""); code != http.StatusRequestEntityTooLarge {
t.Errorf("oversize = %d, want 413", code)
}
ansibleMaxPublishBytes = old
generic := npmRepo(t, h, models.PackageGeneric)
if code, _ := ansiblePublish(t, srv.URL+"/api/v1/local/"+generic+"/api/v3/artifacts/collections/", tb, ""); code != http.StatusMethodNotAllowed {
t.Errorf("publish to generic local = %d, want 405", code)
}
if code, _ := ansiblePublish(t, publish, tb, ""); code != http.StatusAccepted {
t.Errorf("publish after rejects = %d, want 202", code)
}
}
func TestAnsibleLocalPerHost(t *testing.T) {
h, srv := npmTestServer(t)
repo := npmRepo(t, h, models.PackageAnsible)
if code, b := ansiblePublish(t, srv.URL+"/api/v1/local/"+repo+"/api/v3/artifacts/collections/", testCollection(t, "unkin", "host", "1.0.0"), ""); code != http.StatusAccepted {
t.Fatalf("publish = %d: %s", code, b)
}
routes := h.Routes()
path := "/local/" + repo + "/api/v3/collections/unkin/host/versions/1.0.0/"
r := httptest.NewRequest(http.MethodGet, path, nil)
r.Host = "x@evil.com"
w := httptest.NewRecorder()
routes.ServeHTTP(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("bad Host = %d, want 400", w.Code)
}
r = httptest.NewRequest(http.MethodGet, path, nil)
r.Host = "aa.example"
r.Header.Set("X-Forwarded-Proto", "https")
w = httptest.NewRecorder()
routes.ServeHTTP(w, r)
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"download_url":"https://aa.example/api/v1/local/`+repo+`/artifacts/unkin-host-1.0.0.tar.gz"`) {
t.Fatalf("detail = %d: %s", w.Code, w.Body)
}
if cc, vary := w.Header().Get("Cache-Control"), w.Header().Get("Vary"); cc != "no-store" || vary != "Host, X-Forwarded-Proto" {
t.Errorf("Cache-Control %q Vary %q", cc, vary)
}
}
func TestAnsibleVirtualOverLocals(t *testing.T) {
h, srv := npmTestServer(t)
h.virtualEngine = virtual.NewEngine(h.db, nil)
h.virtualEngine.AnsibleLocal = h.LocalAnsible
a, b := npmRepo(t, h, models.PackageAnsible), npmRepo(t, h, models.PackageAnsible)
for _, p := range []struct{ repo, version string }{{a, "1.0.0"}, {b, "1.0.0"}, {b, "2.0.0"}} {
if code, body := ansiblePublish(t, srv.URL+"/api/v1/local/"+p.repo+"/api/v3/artifacts/collections/", testCollection(t, "unkin", "virt", p.version), ""); code != http.StatusAccepted {
t.Fatalf("publish %s to %s = %d: %s", p.version, p.repo, code, body)
}
}
virt := "v" + a
if err := h.db.CreateVirtual(context.Background(), &models.Virtual{Name: virt, PackageType: models.PackageAnsible, Members: []string{a, b}}); err != nil {
t.Fatal(err)
}
base := srv.URL + "/api/v1/virtual/" + virt + "/"
var page struct {
Meta struct{ Count int }
Links struct{ Next *string }
Data []struct{ Version, Href string }
}
getJSON(t, base+"api/v3/collections/unkin/virt/versions/?limit=1", &page)
if page.Meta.Count != 2 || len(page.Data) != 1 || page.Data[0].Version != "1.0.0" || page.Links.Next == nil {
t.Fatalf("page 1 = %+v", page)
}
href := page.Data[0].Href
if !strings.HasPrefix(*page.Links.Next, "/api/v1/virtual/"+virt+"/") {
t.Fatalf("next = %q, want root-relative", *page.Links.Next)
}
getJSON(t, srv.URL+*page.Links.Next, &page)
if len(page.Data) != 1 || page.Data[0].Version != "2.0.0" || page.Links.Next != nil {
t.Fatalf("page 2 = %+v", page)
}
var detail struct {
DownloadURL string `json:"download_url"`
}
getJSON(t, href, &detail)
if detail.DownloadURL != base+a+"/artifacts/unkin-virt-1.0.0.tar.gz" {
t.Fatalf("download_url = %q, want the first member via the virtual", detail.DownloadURL)
}
noFollow := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
resp, err := noFollow.Get(detail.DownloadURL)
if err != nil {
t.Fatal(err)
}
_ = resp.Body.Close()
if resp.StatusCode != http.StatusFound || resp.Header.Get("Location") != srv.URL+"/api/v1/local/"+a+"/artifacts/unkin-virt-1.0.0.tar.gz" {
t.Fatalf("download = %d -> %q", resp.StatusCode, resp.Header.Get("Location"))
}
for _, p := range []string{"api/v3/collections/unkin/nope/", "notamember/x.tar.gz"} {
if code, _ := npmDo(t, http.MethodGet, base+p, ""); code != http.StatusNotFound {
t.Errorf("GET %s = %d, want 404", p, code)
}
}
}
+64
View File
@@ -0,0 +1,64 @@
package v1
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"net/url"
"git.unkin.net/unkin/artifactapi/internal/provider/ansible"
"git.unkin.net/unkin/artifactapi/internal/virtual"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
// serveVirtualAnsible serves the merged Galaxy v3 API and 302s downloads to
// the member owning that version.
func (h *ProxyHandler) serveVirtualAnsible(w http.ResponseWriter, r *http.Request, virt models.Virtual, path, proxyBaseURL string) {
ap := ansible.ParseAPIPath(path)
base := proxyBaseURL + "/api/v1/virtual/" + url.PathEscape(virt.Name)
var doc any
var err error
switch ap.Kind {
case ansible.APIRoot:
doc = ansible.Root()
case ansible.APIVersion:
var body []byte
if body, err = h.virtualEngine.AnsibleVersion(r.Context(), virt, ap.Namespace, ap.Name, ap.Version, proxyBaseURL); err == nil {
doc = json.RawMessage(body)
}
case ansible.APICollection, ansible.APIVersions:
var d *virtual.AnsibleDoc
if d, err = h.virtualEngine.AnsibleVersions(r.Context(), virt, ap.Namespace, ap.Name); err == nil {
if ap.Kind == ansible.APICollection {
doc = ansible.CollectionDoc(base, ap.Namespace, ap.Name, d.Refs)
} else {
limit, offset := ansible.Page(r.URL.Query())
doc = ansible.VersionsPage(base, ap.Namespace, ap.Name, d.Refs, limit, offset)
}
}
default:
loc, ok, err := h.virtualEngine.MemberRedirect(r.Context(), virt, path, r.URL.RawQuery, proxyBaseURL)
switch {
case err != nil:
ansibleError(w, http.StatusBadRequest, err.Error())
case !ok:
ansibleError(w, http.StatusNotFound, "not found")
default:
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Vary", "Host, X-Forwarded-Proto")
http.Redirect(w, r, loc, http.StatusFound)
}
return
}
switch {
case errors.Is(err, virtual.ErrNotFound):
ansibleError(w, http.StatusNotFound, "not found")
case err != nil:
slog.Error("virtual ansible fetch failed", "virtual", virt.Name, "path", path, "error", err)
ansibleError(w, http.StatusBadGateway, "bad gateway")
default:
w.Header().Set("X-Artifact-Source", "virtual")
writeAnsibleJSON(w, http.StatusOK, doc)
}
}
+58
View File
@@ -0,0 +1,58 @@
package v1
import (
"net/url"
"strings"
"testing"
"git.unkin.net/unkin/artifactapi/internal/provider"
)
type plainProvider struct{ provider.Provider }
type forwardingProvider struct{ plainProvider }
func digits(v string) (string, bool) {
if v == "" || strings.Trim(v, "0123456789") != "" {
return "", false
}
return strings.TrimLeft(v, "0"), true
}
func (forwardingProvider) ForwardedQuery(path string) map[string]provider.QueryValidator {
if strings.HasSuffix(path, ".tar.gz") {
return nil
}
return map[string]provider.QueryValidator{"limit": digits, "offset": digits}
}
func TestForwardQuery(t *testing.T) {
tests := []struct {
name string
prov provider.Provider
path string
query string
want string
}{
{"non-forwarder keeps path", plainProvider{}, "a/versions/", "limit=10&offset=5", "a/versions/"},
{"no query", forwardingProvider{}, "a/versions/", "", "a/versions/"},
{"allowlisted params sorted", forwardingProvider{}, "a/versions/", "offset=5&limit=10", "a/versions/?limit=10&offset=5"},
{"unknown params dropped", forwardingProvider{}, "a/versions/", "x=1&limit=10&cb=zz", "a/versions/?limit=10"},
{"only unknown params", forwardingProvider{}, "a/versions/", "x=1", "a/versions/"},
{"first value wins", forwardingProvider{}, "a/versions/", "limit=10&limit=20", "a/versions/?limit=10"},
{"invalid value dropped", forwardingProvider{}, "a/versions/", "limit=1%202&offset=5", "a/versions/?offset=5"},
{"value canonicalised", forwardingProvider{}, "a/versions/", "limit=0010", "a/versions/?limit=10"},
{"path without allowlist", forwardingProvider{}, "a/b-1.0.0.tar.gz", "limit=10", "a/b-1.0.0.tar.gz"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
q, err := url.ParseQuery(tt.query)
if err != nil {
t.Fatal(err)
}
if got := forwardQuery(tt.prov, tt.path, q); got != tt.want {
t.Errorf("forwardQuery = %q, want %q", got, tt.want)
}
})
}
}
+4
View File
@@ -64,6 +64,10 @@ func (h *ProxyHandler) lockGemVersion(w http.ResponseWriter, r *http.Request, re
} }
func (h *ProxyHandler) handleLocalPost(w http.ResponseWriter, r *http.Request) { 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" { if chi.URLParam(r, "*") != "api/v1/gems" {
gemsText(w, http.StatusNotFound, "not found") gemsText(w, http.StatusNotFound, "not found")
return return
+4 -3
View File
@@ -231,8 +231,9 @@ func TestGemsLockTimeoutIs503(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if ok, err := redis.AcquireLock(context.Background(), repo, "_gemlock/busy-1.0.0", time.Minute); !ok || err != nil { token, err := redis.AcquireLock(context.Background(), repo, "_gemlock/busy-1.0.0", time.Minute)
t.Fatalf("hold lock = %v %v", ok, err) if token == "" || err != nil {
t.Fatalf("hold lock = %q %v", token, err)
} }
prev := gemsLockWait prev := gemsLockWait
gemsLockWait = 100 * time.Millisecond gemsLockWait = 100 * time.Millisecond
@@ -244,7 +245,7 @@ func TestGemsLockTimeoutIs503(t *testing.T) {
if code, _ := npmDo(t, http.MethodDelete, base+"api/v1/gems/yank?gem_name=busy&version=1.0.0", ""); code != http.StatusServiceUnavailable { 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) t.Fatalf("yank under held lock = %d, want 503", code)
} }
if err := redis.ReleaseLock(context.Background(), repo, "_gemlock/busy-1.0.0"); err != nil { if err := redis.ReleaseLock(context.Background(), repo, "_gemlock/busy-1.0.0", token); err != nil {
t.Fatal(err) 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 { if code, body := npmDo(t, http.MethodPost, base+"api/v1/gems", string(testGemFile(t, "busy", "1.0.0", "ruby"))); code != http.StatusOK {
+33
View File
@@ -0,0 +1,33 @@
package v1
import (
"errors"
"log/slog"
"net/http"
"git.unkin.net/unkin/artifactapi/internal/virtual"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
// serveVirtualGoProxy serves the merged @v/list and 302s @latest and version
// files to the member owning that version.
func (h *ProxyHandler) serveVirtualGoProxy(w http.ResponseWriter, r *http.Request, virt models.Virtual, path string) {
body, loc, err := h.virtualEngine.GoProxy(r.Context(), virt, path, r.URL.RawQuery)
switch {
case errors.Is(err, virtual.ErrNotFound):
http.Error(w, "not found", http.StatusNotFound)
case errors.Is(err, virtual.ErrBadPath):
http.Error(w, err.Error(), http.StatusBadRequest)
case err != nil:
slog.Error("virtual goproxy fetch failed", "virtual", virt.Name, "path", path, "error", err)
http.Error(w, "bad gateway", http.StatusBadGateway)
case loc != "":
w.Header().Set("Cache-Control", "no-store")
http.Redirect(w, r, loc, http.StatusFound)
default:
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("X-Artifact-Source", "virtual")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(body)
}
}
+67 -3
View File
@@ -2,20 +2,24 @@ package v1
import ( import (
"bytes" "bytes"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"log/slog" "log/slog"
"net" "net"
"net/http" "net/http"
"net/url"
"strconv" "strconv"
"strings" "strings"
"time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
v2 "git.unkin.net/unkin/artifactapi/internal/api/v2" v2 "git.unkin.net/unkin/artifactapi/internal/api/v2"
"git.unkin.net/unkin/artifactapi/internal/database" "git.unkin.net/unkin/artifactapi/internal/database"
"git.unkin.net/unkin/artifactapi/internal/provider" "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/proxy"
"git.unkin.net/unkin/artifactapi/internal/storage" "git.unkin.net/unkin/artifactapi/internal/storage"
"git.unkin.net/unkin/artifactapi/internal/virtual" "git.unkin.net/unkin/artifactapi/internal/virtual"
@@ -115,11 +119,11 @@ func (h *ProxyHandler) handleProxy(w http.ResponseWriter, r *http.Request) {
} }
func (h *ProxyHandler) serveFetch(w http.ResponseWriter, r *http.Request, remote *models.Remote, prov provider.Provider, path string) { 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 { if err != nil {
var proxyErr *proxy.ProxyError var proxyErr *proxy.ProxyError
if errors.As(err, &proxyErr) { if errors.As(err, &proxyErr) {
http.Error(w, proxyErr.Message, proxyErr.Status) writeProxyError(w, proxyErr)
return return
} }
slog.Error("proxy fetch failed", "remote", remote.Name, "path", path, "error", err) slog.Error("proxy fetch failed", "remote", remote.Name, "path", path, "error", err)
@@ -147,6 +151,24 @@ func (h *ProxyHandler) serveFetch(w http.ResponseWriter, r *http.Request, remote
io.Copy(w, body) 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 { func rewritesMutable(prov provider.Provider, path string) bool {
_, sr := prov.(provider.ServeRewriter) _, sr := prov.(provider.ServeRewriter)
_, rr := prov.(provider.ResponseRewriter) _, rr := prov.(provider.ResponseRewriter)
@@ -174,7 +196,7 @@ func (h *ProxyHandler) handleProxyHead(w http.ResponseWriter, r *http.Request) {
if err != nil { if err != nil {
var proxyErr *proxy.ProxyError var proxyErr *proxy.ProxyError
if errors.As(err, &proxyErr) { if errors.As(err, &proxyErr) {
http.Error(w, proxyErr.Message, proxyErr.Status) writeProxyError(w, proxyErr)
return return
} }
slog.Error("proxy head failed", "remote", remote.Name, "path", path, "error", err) slog.Error("proxy head failed", "remote", remote.Name, "path", path, "error", err)
@@ -209,6 +231,18 @@ func (h *ProxyHandler) handleVirtual(w http.ResponseWriter, r *http.Request) {
h.serveVirtualGems(w, r, *virt, path, proxyBaseURL) h.serveVirtualGems(w, r, *virt, path, proxyBaseURL)
return 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) loc, ok, err := h.virtualEngine.MemberRedirect(r.Context(), *virt, path, r.URL.RawQuery, proxyBaseURL)
if err != nil { if err != nil {
@@ -274,6 +308,17 @@ func (h *ProxyHandler) handleLocal(w http.ResponseWriter, r *http.Request) {
} }
} }
if remote.PackageType == models.PackageAnsible {
if ap := ansible.ParseAPIPath(path); ap.Kind != ansible.APIUnknown {
h.serveLocalAnsible(w, r, remote, ap)
return
}
if !strings.HasPrefix(path, ansible.ArtifactPrefix) {
http.Error(w, "not found", http.StatusNotFound)
return
}
}
prov, _ := provider.Get(remote.PackageType) prov, _ := provider.Get(remote.PackageType)
if indexer, ok := prov.(provider.LocalIndexer); ok { if indexer, ok := prov.(provider.LocalIndexer); ok {
if indexer.ServeLocalIndex(w, r, h.db, remote.Name, path) { if indexer.ServeLocalIndex(w, r, h.db, remote.Name, path) {
@@ -325,6 +370,25 @@ func Scheme(r *http.Request) string {
return "http" 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 // rewriteServed rewrites a cached body for the requesting host and marks the
// response uncacheable by shared caches, since it varies per Host. // 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) { 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" "fmt"
"io" "io"
"net/http" "net/http"
"os"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@@ -83,7 +84,22 @@ func (h *LocalHandler) uploadValidated(w http.ResponseWriter, r *http.Request, r
return 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 { if err != nil {
http.Error(w, fmt.Sprintf("store failed: %v", err), http.StatusInternalServerError) http.Error(w, fmt.Sprintf("store failed: %v", err), http.StatusInternalServerError)
return 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)) 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) { 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) existing, err := h.db.GetLocalFile(r.Context(), remote.Name, filePath)
if err != nil { 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 const defaultMutableTTL = 3600
func (h *RemotesHandler) create(w http.ResponseWriter, r *http.Request) { 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 { if err := json.NewDecoder(r.Body).Decode(&remote); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest) http.Error(w, "invalid json", http.StatusBadRequest)
return return
@@ -104,6 +104,14 @@ func (h *RemotesHandler) create(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return 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 { if err := h.db.CreateRemote(r.Context(), &remote); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
@@ -126,6 +134,7 @@ func (h *RemotesHandler) update(w http.ResponseWriter, r *http.Request) {
var remote models.Remote var remote models.Remote
if oldKnown { if oldKnown {
remote.MutableTTL = existing.MutableTTL remote.MutableTTL = existing.MutableTTL
remote.QuarantineDays = existing.QuarantineDays
} }
if err := json.NewDecoder(r.Body).Decode(&remote); err != nil { if err := json.NewDecoder(r.Body).Decode(&remote); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest) 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) http.Error(w, err.Error(), http.StatusBadRequest)
return 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 { if err := h.db.UpdateRemote(r.Context(), &remote); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
+109
View File
@@ -55,3 +55,112 @@ func TestMutableTTLOmittedVsZero(t *testing.T) {
t.Errorf("explicit 0 stored as %d", got) 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 ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"fmt"
"log/slog" "log/slog"
"net/http" "net/http"
"slices" "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 // Require gates every request through next on a valid token. A nil receiver
// passes everything through. // passes everything through.
func (b *BearerOIDC) Require(next http.Handler) http.Handler { 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 { if b == nil {
return next return next
} }
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
scheme, raw, _ := strings.Cut(strings.TrimSpace(r.Header.Get("Authorization")), " ") raw := token(r)
raw = strings.TrimSpace(raw) if raw == "" {
if !strings.EqualFold(scheme, "Bearer") || raw == "" { unauthorized(w, challenge, "missing token")
unauthorized(w, "missing bearer token")
return return
} }
v, err := b.getVerifier(r.Context()) sub, err := b.verify(r.Context(), raw)
if err != nil { if errors.Is(err, errIssuerUnavailable) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusServiceUnavailable) w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write([]byte(`{"error":"token issuer unavailable"}`)) _, _ = w.Write([]byte(`{"error":"token issuer unavailable"}`))
return return
} }
tok, err := v.Verify(r.Context(), raw)
if err != nil { if err != nil {
slog.Info("bearer token rejected", "error", err) slog.Info("bearer token rejected", "error", err)
unauthorized(w, "invalid token") unauthorized(w, challenge, "invalid token")
return return
} }
if len(b.audiences) > 0 && !slices.ContainsFunc(tok.Audience, func(a string) bool { return slices.Contains(b.audiences, a) }) { slog.Info("bearer token accepted", "sub", sub, "method", r.Method, "path", r.URL.Path)
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)
next.ServeHTTP(w, r) 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. // RequireWrites gates only mutating methods.
func (b *BearerOIDC) RequireWrites(next http.Handler) http.Handler { 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) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method { switch r.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions: 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("Content-Type", "application/json")
w.Header().Set("WWW-Authenticate", "Bearer") w.Header().Set("WWW-Authenticate", challenge)
w.WriteHeader(http.StatusUnauthorized) w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"` + msg + `"}`)) _, _ = w.Write([]byte(`{"error":"` + msg + `"}`))
} }
+88
View File
@@ -8,6 +8,7 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strconv" "strconv"
"strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
"testing" "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) { func TestLock(t *testing.T) {
requireRedis(t) requireRedis(t)
ctx := context.Background() ctx := context.Background()
ok, err := testRedis.AcquireLock(ctx, "r", "lockpath", time.Minute) token, err := testRedis.AcquireLock(ctx, "r", "lockpath", time.Minute)
if err != nil || !ok { if err != nil || token == "" {
t.Fatalf("first acquire should succeed: %v %v", ok, err) 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") 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) t.Fatal(err)
} }
if ok, _ := testRedis.AcquireLock(ctx, "r", "lockpath", time.Minute); !ok { if again, _ := testRedis.AcquireLock(ctx, "r", "lockpath", time.Minute); again != "" {
t.Error("acquire should succeed after release") 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 ( import (
"context" "context"
"crypto/rand"
"fmt" "fmt"
"strings" "strings"
"time" "time"
@@ -46,15 +47,23 @@ func (r *Redis) CheckTTL(ctx context.Context, remote, path string) (bool, error)
return exists > 0, nil 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) key := fmt.Sprintf("lock:%s:%s", remote, path)
ok, err := r.client.SetNX(ctx, key, "1", ttl).Result() token := rand.Text()
return ok, err 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) 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. // 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 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) { func (r *Redis) GetToken(ctx context.Context, key string) (string, error) {
val, err := r.client.Get(ctx, "token:"+key).Result() val, err := r.client.Get(ctx, "token:"+key).Result()
if err == redis.Nil { if err == redis.Nil {
+10
View File
@@ -61,6 +61,8 @@ type Config struct {
// Bearer-JWT auth on configuration writes; empty issuer disables it. // Bearer-JWT auth on configuration writes; empty issuer disables it.
OIDCIssuer string OIDCIssuer string
OIDCAudiences []string OIDCAudiences []string
// AuthUploads extends that auth to local repo uploads, deletes and evictions.
AuthUploads bool
} }
func (c *Config) DatabaseDSN() string { func (c *Config) DatabaseDSN() string {
@@ -105,6 +107,13 @@ func Load() (*Config, error) {
if oidcIssuer != "" && len(oidcAudiences) == 0 { if oidcIssuer != "" && len(oidcAudiences) == 0 {
return nil, fmt.Errorf("ARTIFACTAPI_OIDC_AUDIENCE is required when ARTIFACTAPI_OIDC_ISSUER is set") 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{ cfg := &Config{
ListenAddr: getenv("LISTEN_ADDR", ":8000"), ListenAddr: getenv("LISTEN_ADDR", ":8000"),
@@ -142,6 +151,7 @@ func Load() (*Config, error) {
OIDCIssuer: oidcIssuer, OIDCIssuer: oidcIssuer,
OIDCAudiences: oidcAudiences, OIDCAudiences: oidcAudiences,
AuthUploads: authUploads,
} }
return cfg, nil return cfg, nil
+20
View File
@@ -81,3 +81,23 @@ func TestLoadOIDC(t *testing.T) {
t.Error("expected error for issuer without audience") 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 // ClaimGitHubAlpineSyncLease atomically claims the per-remote github_alpine sync
// lease. See claimSyncLease. // lease. See claimSyncLease.
func (db *DB) ClaimGitHubAlpineSyncLease(ctx context.Context, remoteName, owner string, freshness, lease time.Duration) (bool, string, error) { 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) return db.claimSyncLease(ctx, "github_alpine_sync_state", remoteName, owner, freshness, lease, interval, phase)
} }
// ReleaseGitHubAlpineSyncLease records a github_alpine scan outcome and frees the // ReleaseGitHubAlpineSyncLease records a github_alpine scan outcome and frees the
+62
View File
@@ -307,6 +307,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) { func TestVirtualsCRUD(t *testing.T) {
requireDB(t) requireDB(t)
if err := testDB.CreateVirtual(ctx(), &models.Virtual{ if err := testDB.CreateVirtual(ctx(), &models.Virtual{
+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 // ClaimGitHubDebSyncLease atomically claims the per-remote github_deb sync
// lease. See claimSyncLease. // lease. See claimSyncLease.
func (db *DB) ClaimGitHubDebSyncLease(ctx context.Context, remoteName, owner string, freshness, lease time.Duration) (bool, string, error) { 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) return db.claimSyncLease(ctx, "github_deb_sync_state", remoteName, owner, freshness, lease, interval, phase)
} }
// ReleaseGitHubDebSyncLease records a github_deb scan outcome and frees the // 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 const lease = 15 * time.Minute
freshness := time.Hour 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 { if err != nil || !claimed {
t.Fatalf("replica-1 first claim: claimed=%v err=%v", claimed, err) 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) 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 { if err != nil {
t.Fatalf("replica-2 claim err: %v", err) t.Fatalf("replica-2 claim err: %v", err)
} }
@@ -49,7 +49,7 @@ func TestGitHubDebSyncLease(t *testing.T) {
t.Fatalf("release: %v", err) 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 { if err != nil {
t.Fatalf("replica-2 recency claim err: %v", err) 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") 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 { if err != nil || !claimed4 {
t.Fatalf("prime claim: claimed=%v err=%v", claimed4, err) 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. // ClaimGitHubSyncLease atomically claims the per-remote github_rpm sync lease.
// See claimSyncLease. // See claimSyncLease.
func (db *DB) ClaimGitHubSyncLease(ctx context.Context, remoteName, owner string, freshness, lease time.Duration) (bool, string, error) { 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) 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. // 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 // (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 // 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 // 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 // last synced before the current slot (interval set) or longer than freshness
// recency gate but still honours a pending retry's backoff. The returned etag is // ago (otherwise). With interval set, slots start at phase past each multiple
// the stored releases-list ETag, shared across replicas so a conditional request // 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. // 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, ` row := db.Pool.QueryRow(ctx, `
INSERT INTO `+table+` AS s (remote_name, sync_lease_owner, sync_lease_expires) 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 ON CONFLICT (remote_name) DO UPDATE
SET sync_lease_owner = $2, SET sync_lease_owner = $2,
sync_lease_expires = now() + make_interval(secs => $4) sync_lease_expires = now() + make_interval(secs => $4)
WHERE (CASE WHEN s.next_retry_at IS NOT NULL THEN s.next_retry_at <= now() 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()) AND (s.sync_lease_expires IS NULL OR s.sync_lease_expires < now())
RETURNING s.etag RETURNING s.etag
`, remoteName, owner, freshness.Seconds(), lease.Seconds()) `, remoteName, owner, freshness.Seconds(), lease.Seconds(), interval.Seconds(), phase.Seconds())
var etag string var etag string
if err := row.Scan(&etag); err != nil { if err := row.Scan(&etag); err != nil {
+198 -5
View File
@@ -1,6 +1,10 @@
package database package database
import ( import (
"context"
"fmt"
"math"
"sync/atomic"
"testing" "testing"
"time" "time"
@@ -30,7 +34,7 @@ func TestGitHubSyncLease(t *testing.T) {
freshness := time.Hour freshness := time.Hour
// First claim on a never-synced remote wins; etag starts empty. // 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 { if err != nil || !claimed {
t.Fatalf("replica-1 first claim: claimed=%v err=%v", claimed, err) 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. // 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 { if err != nil {
t.Fatalf("replica-2 claim err: %v", err) 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. // 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 { if err != nil {
t.Fatalf("replica-2 recency claim err: %v", err) 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. // 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 { if err != nil || !claimed4 {
t.Fatalf("prime claim: claimed=%v err=%v", claimed4, err) t.Fatalf("prime claim: claimed=%v err=%v", claimed4, err)
} }
@@ -108,7 +112,7 @@ func TestGitHubSyncLeaseFailureBackoff(t *testing.T) {
freshness := time.Hour freshness := time.Hour
claim := func(f time.Duration) (bool, string) { claim := func(f time.Duration) (bool, string) {
t.Helper() 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 { if err != nil {
t.Fatalf("claim: %v", err) t.Fatalf("claim: %v", err)
} }
@@ -207,3 +211,192 @@ func TestGitHubSyncLeaseFailureBackoff(t *testing.T) {
t.Fatal("claimed inside freshness window after a successful sync") 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 -11
View File
@@ -3,17 +3,19 @@ package database
import ( import (
"context" "context"
"github.com/jackc/pgx/v5"
"git.unkin.net/unkin/artifactapi/pkg/models" "git.unkin.net/unkin/artifactapi/pkg/models"
) )
const remoteCols = `name, package_type, repo_type, base_url, mirrorlist, mirror_strategy, description, username, password, 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, patterns, blocklist, mutable_patterns, immutable_patterns,
ban_tags_enabled, ban_tags, ban_tags_enabled, ban_tags,
quarantine_enabled, quarantine_days, stale_on_error, quarantine_enabled, quarantine_days, stale_on_error,
releases_remote, managed_by, releases_remote, managed_by,
upstream_dial_timeout, upstream_tls_timeout, upstream_response_header_timeout, upstream_dial_timeout, upstream_tls_timeout, upstream_response_header_timeout,
created_at, updated_at` serve_stale, created_at, updated_at`
// normalizeMirrorStrategy maps an empty strategy to the round_robin default so // normalizeMirrorStrategy maps an empty strategy to the round_robin default so
// the NOT NULL mirror_strategy column always stores a canonical value. // the NOT NULL mirror_strategy column always stores a canonical value.
@@ -27,13 +29,13 @@ func normalizeMirrorStrategy(s string) string {
func scanRemote(scanner interface{ Scan(...any) error }, r *models.Remote) error { func scanRemote(scanner interface{ Scan(...any) error }, r *models.Remote) error {
return scanner.Scan( return scanner.Scan(
&r.Name, &r.PackageType, &r.RepoType, &r.BaseURL, &r.Mirrorlist, &r.MirrorStrategy, &r.Description, &r.Username, &r.Password, &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.Patterns, &r.Blocklist, &r.MutablePatterns, &r.ImmutablePatterns,
&r.BanTagsEnabled, &r.BanTags, &r.BanTagsEnabled, &r.BanTags,
&r.QuarantineEnabled, &r.QuarantineDays, &r.StaleOnError, &r.QuarantineEnabled, &r.QuarantineDays, &r.StaleOnError,
&r.ReleasesRemote, &r.ManagedBy, &r.ReleasesRemote, &r.ManagedBy,
&r.UpstreamDialTimeout, &r.UpstreamTLSTimeout, &r.UpstreamResponseHeaderTimeout, &r.UpstreamDialTimeout, &r.UpstreamTLSTimeout, &r.UpstreamResponseHeaderTimeout,
&r.CreatedAt, &r.UpdatedAt, &r.ServeStale, &r.CreatedAt, &r.UpdatedAt,
) )
} }
@@ -74,8 +76,8 @@ func (db *DB) CreateRemote(ctx context.Context, r *models.Remote) error {
quarantine_enabled, quarantine_days, stale_on_error, quarantine_enabled, quarantine_days, stale_on_error,
releases_remote, managed_by, releases_remote, managed_by,
upstream_dial_timeout, upstream_tls_timeout, upstream_response_header_timeout, upstream_dial_timeout, upstream_tls_timeout, upstream_response_header_timeout,
mirror_strategy mirror_strategy, sync_interval, serve_stale
) 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) ) 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)
`, `,
r.Name, r.PackageType, r.RepoType, r.BaseURL, r.Mirrorlist, r.Description, r.Username, r.Password, r.Name, r.PackageType, r.RepoType, r.BaseURL, r.Mirrorlist, r.Description, r.Username, r.Password,
r.ImmutableTTL, r.MutableTTL, r.CheckMutable, r.ImmutableTTL, r.MutableTTL, r.CheckMutable,
@@ -84,7 +86,7 @@ func (db *DB) CreateRemote(ctx context.Context, r *models.Remote) error {
r.QuarantineEnabled, r.QuarantineDays, r.StaleOnError, r.QuarantineEnabled, r.QuarantineDays, r.StaleOnError,
r.ReleasesRemote, r.ManagedBy, r.ReleasesRemote, r.ManagedBy,
r.UpstreamDialTimeout, r.UpstreamTLSTimeout, r.UpstreamResponseHeaderTimeout, r.UpstreamDialTimeout, r.UpstreamTLSTimeout, r.UpstreamResponseHeaderTimeout,
normalizeMirrorStrategy(r.MirrorStrategy), normalizeMirrorStrategy(r.MirrorStrategy), r.SyncInterval, r.ServeStale,
) )
return err return err
} }
@@ -99,7 +101,7 @@ func (db *DB) UpdateRemote(ctx context.Context, r *models.Remote) error {
quarantine_enabled=$17, quarantine_days=$18, stale_on_error=$19, quarantine_enabled=$17, quarantine_days=$18, stale_on_error=$19,
releases_remote=$20, managed_by=$21, releases_remote=$20, managed_by=$21,
upstream_dial_timeout=$22, upstream_tls_timeout=$23, upstream_response_header_timeout=$24, 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,
updated_at=NOW() updated_at=NOW()
WHERE name=$1 WHERE name=$1
`, `,
@@ -111,12 +113,21 @@ func (db *DB) UpdateRemote(ctx context.Context, r *models.Remote) error {
r.ReleasesRemote, r.ManagedBy, r.ReleasesRemote, r.ManagedBy,
r.UpstreamDialTimeout, r.UpstreamTLSTimeout, r.UpstreamResponseHeaderTimeout, r.UpstreamDialTimeout, r.UpstreamTLSTimeout, r.UpstreamResponseHeaderTimeout,
r.Mirrorlist, r.Mirrorlist,
normalizeMirrorStrategy(r.MirrorStrategy), normalizeMirrorStrategy(r.MirrorStrategy), r.SyncInterval, r.ServeStale,
) )
return err 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 { func (db *DB) DeleteRemote(ctx context.Context, name string) error {
_, err := db.Pool.Exec(ctx, `DELETE FROM remotes WHERE name = $1`, name) return pgx.BeginFunc(ctx, db.Pool, func(tx pgx.Tx) error {
return err 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
})
} }
+9 -9
View File
@@ -8,12 +8,12 @@ import (
func (db *DB) GetVirtual(ctx context.Context, name string) (*models.Virtual, error) { func (db *DB) GetVirtual(ctx context.Context, name string) (*models.Virtual, error) {
row := db.Pool.QueryRow(ctx, ` 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 FROM virtuals WHERE name = $1
`, name) `, name)
var v models.Virtual 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 { if err != nil {
return nil, err 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) { func (db *DB) ListVirtuals(ctx context.Context) ([]models.Virtual, error) {
rows, err := db.Pool.Query(ctx, ` 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 FROM virtuals ORDER BY name
`) `)
if err != nil { if err != nil {
@@ -33,7 +33,7 @@ func (db *DB) ListVirtuals(ctx context.Context) ([]models.Virtual, error) {
var virtuals []models.Virtual var virtuals []models.Virtual
for rows.Next() { for rows.Next() {
var v models.Virtual 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 return nil, err
} }
virtuals = append(virtuals, v) 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 { func (db *DB) CreateVirtual(ctx context.Context, v *models.Virtual) error {
_, err := db.Pool.Exec(ctx, ` _, err := db.Pool.Exec(ctx, `
INSERT INTO virtuals (name, package_type, description, members, managed_by) INSERT INTO virtuals (name, package_type, description, members, managed_by, parallel_fetch)
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, $5, $6)
`, v.Name, v.PackageType, v.Description, v.Members, v.ManagedBy) `, v.Name, v.PackageType, v.Description, v.Members, v.ManagedBy, v.ParallelFetch)
return err return err
} }
func (db *DB) UpdateVirtual(ctx context.Context, v *models.Virtual) error { func (db *DB) UpdateVirtual(ctx context.Context, v *models.Virtual) error {
_, err := db.Pool.Exec(ctx, ` _, err := db.Pool.Exec(ctx, `
UPDATE virtuals SET 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 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 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 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{} seen := map[string]bool{}
for _, rel := range releases { for _, rel := range releases {
if rel.Draft { 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) meta, err := p.deriveAsset(ctx, remote, asset, fp)
if err != nil { if err != nil {
slog.Warn("github_alpine: derive asset failed", "remote", remote.Name, "asset", asset.Name, "error", err) 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 continue
} }
if err := inserter.InsertAlpineMetadata(ctx, meta); err != nil { if err := inserter.InsertAlpineMetadata(ctx, meta); err != nil {
slog.Error("github_alpine: insert metadata failed", "remote", remote.Name, "asset", asset.Name, "error", err) 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 continue
} }
slog.Info("github_alpine: derived asset", "remote", remote.Name, "name", meta.Name, "version", meta.Version, "arch", meta.Arch) 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) _ = deleter.DeleteAlpineMetadata(ctx, remote.Name, fp)
} }
} }
return newEtag, true, nil return newEtag, true, errors.Join(failed...)
} }
type ghRelease struct { type ghRelease struct {
@@ -496,7 +503,7 @@ func (p *GitHubProvider) deriveAsset(ctx context.Context, remote models.Remote,
return nil, err return nil, err
} }
if meta.Name == "" || meta.Arch == "" { 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 meta.RepoName = remote.Name
@@ -526,13 +533,13 @@ func (p *GitHubProvider) fetchPkginfo(ctx context.Context, remote models.Remote,
} }
meta, complete, perr := pkginfoFromPrefix(body) meta, complete, perr := pkginfoFromPrefix(body)
if perr != nil { 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 { if complete {
return meta, nil return meta, nil
} }
if full || n >= p.headerMax { 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 n *= 2
if n > p.headerMax { 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 // 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) { 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) req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
if err != nil { if err != nil {
@@ -616,15 +623,9 @@ func (p *GitHubProvider) rangeGet(ctx context.Context, remote models.Remote, dow
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent { 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))
} }
return provider.ReadPrefix(resp, n)
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
} }
// assetPath is the package's location relative to github.com — the path the // assetPath is the package's location relative to github.com — the path the
+22 -3
View File
@@ -8,6 +8,7 @@ import (
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -88,15 +89,17 @@ type githubFixture struct {
notModHit int notModHit int
releaseAuth string releaseAuth string
assetAuth string assetAuth string
assetFail map[string]int // asset filename -> downloads left to answer 500
mu sync.Mutex mu sync.Mutex
} }
func newGitHubFixture(t *testing.T, withDigest bool) *githubFixture { func newGitHubFixture(t *testing.T, withDigest bool) *githubFixture {
t.Helper() t.Helper()
f := &githubFixture{ f := &githubFixture{
apkBytes: map[string][]byte{}, apkBytes: map[string][]byte{},
rangeHit: map[string]int{}, rangeHit: map[string]int{},
fullHit: 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") 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") rng := r.Header.Get("Range")
f.mu.Lock() 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") f.assetAuth = r.Header.Get("Authorization")
if rng != "" { if rng != "" {
f.rangeHit[name]++ 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 { type SyncStore interface {
provider.RemoteMetadataStore provider.RemoteMetadataStore
ListGitHubAlpineRemotes(ctx context.Context) ([]models.Remote, error) 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 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 // 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) { func (s *Syncer) schedule(ctx context.Context) {
remotes, err := s.store.ListGitHubAlpineRemotes(ctx) remotes, err := s.store.ListGitHubAlpineRemotes(ctx)
if err != nil { 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. // 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) { func (s *Syncer) EnqueuePrime(remote models.Remote) {
if s == nil { if s == nil || remote.SyncInterval > 0 {
return return
} }
s.enqueue(remote, true) s.enqueue(remote, true)
@@ -189,16 +192,16 @@ func (s *Syncer) process(ctx context.Context, job syncJob) {
s.mu.Unlock() s.mu.Unlock()
}() }()
ttl := time.Duration(job.remote.MutableTTL) * time.Second ttl := job.remote.SyncPeriod(defaultSyncFreshness)
if ttl <= 0 {
ttl = defaultSyncFreshness
}
freshness := ttl freshness := ttl
var interval, phase time.Duration
if job.prime { if job.prime {
freshness = 0 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 { if err != nil {
slog.Error("github_alpine syncer: claim lease", "remote", job.remote.Name, "error", err) slog.Error("github_alpine syncer: claim lease", "remote", job.remote.Name, "error", err)
return return
+97 -2
View File
@@ -28,6 +28,10 @@ type fakeSyncStore struct {
lastSynced map[string]time.Time lastSynced map[string]time.Time
etags map[string]string etags map[string]string
retryAt map[string]time.Time retryAt map[string]time.Time
freshness time.Duration
interval time.Duration
phase time.Duration
attempts int
} }
func newFakeSyncStore() *fakeSyncStore { func newFakeSyncStore() *fakeSyncStore {
@@ -47,9 +51,11 @@ func (f *fakeSyncStore) ListGitHubAlpineRemotes(_ context.Context) ([]models.Rem
return append([]models.Remote(nil), f.remotes...), nil 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() f.mu.Lock()
defer f.mu.Unlock() defer f.mu.Unlock()
f.freshness, f.interval, f.phase = freshness, interval, phase
f.attempts++
now := time.Now() now := time.Now()
ls, hasLS := f.lastSynced[name] ls, hasLS := f.lastSynced[name]
exp, hasExp := f.leaseExp[name] exp, hasExp := f.leaseExp[name]
@@ -223,7 +229,7 @@ func TestSyncerLeasePreventsSecondReplica(t *testing.T) {
s := newSyncer(store, p, testSyncConfig()) s := newSyncer(store, p, testSyncConfig())
remote := fx.remote() 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 { if err != nil || !claimed {
t.Fatalf("replica-1 claim: claimed=%v err=%v", claimed, err) 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) 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 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{} seen := map[string]bool{}
for _, rel := range releases { for _, rel := range releases {
if rel.Draft { 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) meta, err := p.deriveAsset(ctx, remote, asset, fp)
if err != nil { if err != nil {
slog.Warn("github_deb: derive asset failed", "remote", remote.Name, "asset", asset.Name, "error", err) 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 continue
} }
if err := store.InsertDebMetadata(ctx, meta); err != nil { if err := store.InsertDebMetadata(ctx, meta); err != nil {
slog.Error("github_deb: insert metadata failed", "remote", remote.Name, "asset", asset.Name, "error", err) 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 continue
} }
slog.Info("github_deb: derived asset", "remote", remote.Name, "name", meta.Name, "version", meta.Version, "arch", meta.Architecture) 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) _ = store.DeleteDebMetadata(ctx, remote.Name, fp)
} }
} }
return newEtag, true, nil return newEtag, true, errors.Join(failed...)
} }
type ghRelease struct { type ghRelease struct {
@@ -468,7 +475,7 @@ func (p *GitHubProvider) deriveAsset(ctx context.Context, remote models.Remote,
Size: asset.Size, Size: asset.Size,
} }
if meta.Name == "" { 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 // 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) control, complete, perr := controlFromPrefix(body)
if perr != nil { if perr != nil {
return "", fmt.Errorf("parse deb control: %w", perr) return "", fmt.Errorf("%w: parse deb control: %w", provider.ErrInvalidPackage, perr)
} }
if complete { if complete {
return control, nil return control, nil
} }
if full || n >= p.headerMax { 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 n *= 2
if n > p.headerMax { 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 // 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) { 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) req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
if err != nil { if err != nil {
@@ -589,15 +596,9 @@ func (p *GitHubProvider) rangeGet(ctx context.Context, remote models.Remote, dow
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent { 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))
} }
return provider.ReadPrefix(resp, n)
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
} }
func (p *GitHubProvider) computeSHA256(ctx context.Context, remote models.Remote, downloadURL string) (string, error) { 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() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { 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() h := sha256.New()
+22 -3
View File
@@ -7,6 +7,7 @@ import (
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -81,15 +82,17 @@ type githubFixture struct {
notModHit int notModHit int
releaseAuth string releaseAuth string
assetAuth string assetAuth string
assetFail map[string]int // asset filename -> downloads left to answer 500
mu sync.Mutex mu sync.Mutex
} }
func newGitHubFixture(t *testing.T, withDigest bool) *githubFixture { func newGitHubFixture(t *testing.T, withDigest bool) *githubFixture {
t.Helper() t.Helper()
f := &githubFixture{ f := &githubFixture{
debBytes: map[string][]byte{}, debBytes: map[string][]byte{},
rangeHit: map[string]int{}, rangeHit: map[string]int{},
fullHit: 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") 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") rng := r.Header.Get("Range")
f.mu.Lock() 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") f.assetAuth = r.Header.Get("Authorization")
if rng != "" { if rng != "" {
f.rangeHit[name]++ f.rangeHit[name]++
@@ -460,3 +469,13 @@ func TestGitHubAssetPatternFilter(t *testing.T) {
t.Fatalf("pattern filter failed, rows=%+v", rows) 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 { type SyncStore interface {
provider.RemoteMetadataStore provider.RemoteMetadataStore
ListGitHubDebRemotes(ctx context.Context) ([]models.Remote, error) 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 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 // 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) { func (s *Syncer) schedule(ctx context.Context) {
remotes, err := s.store.ListGitHubDebRemotes(ctx) remotes, err := s.store.ListGitHubDebRemotes(ctx)
if err != nil { 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. // 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) { func (s *Syncer) EnqueuePrime(remote models.Remote) {
if s == nil { if s == nil || remote.SyncInterval > 0 {
return return
} }
s.enqueue(remote, true) s.enqueue(remote, true)
@@ -189,16 +192,16 @@ func (s *Syncer) process(ctx context.Context, job syncJob) {
s.mu.Unlock() s.mu.Unlock()
}() }()
ttl := time.Duration(job.remote.MutableTTL) * time.Second ttl := job.remote.SyncPeriod(defaultSyncFreshness)
if ttl <= 0 {
ttl = defaultSyncFreshness
}
freshness := ttl freshness := ttl
var interval, phase time.Duration
if job.prime { if job.prime {
freshness = 0 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 { if err != nil {
slog.Error("github_deb syncer: claim lease", "remote", job.remote.Name, "error", err) slog.Error("github_deb syncer: claim lease", "remote", job.remote.Name, "error", err)
return return
+97 -2
View File
@@ -28,6 +28,10 @@ type fakeSyncStore struct {
lastSynced map[string]time.Time lastSynced map[string]time.Time
etags map[string]string etags map[string]string
retryAt map[string]time.Time retryAt map[string]time.Time
freshness time.Duration
interval time.Duration
phase time.Duration
attempts int
} }
func newFakeSyncStore() *fakeSyncStore { func newFakeSyncStore() *fakeSyncStore {
@@ -47,9 +51,11 @@ func (f *fakeSyncStore) ListGitHubDebRemotes(_ context.Context) ([]models.Remote
return append([]models.Remote(nil), f.remotes...), nil 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() f.mu.Lock()
defer f.mu.Unlock() defer f.mu.Unlock()
f.freshness, f.interval, f.phase = freshness, interval, phase
f.attempts++
now := time.Now() now := time.Now()
ls, hasLS := f.lastSynced[name] ls, hasLS := f.lastSynced[name]
exp, hasExp := f.leaseExp[name] exp, hasExp := f.leaseExp[name]
@@ -223,7 +229,7 @@ func TestSyncerLeasePreventsSecondReplica(t *testing.T) {
s := newSyncer(store, p, testSyncConfig()) s := newSyncer(store, p, testSyncConfig())
remote := fx.remote() 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 { if err != nil || !claimed {
t.Fatalf("replica-1 claim: claimed=%v err=%v", claimed, err) 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) 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)
}
}
+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))) h.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(remote.Username+":"+remote.Password)))
return h, nil return h, nil
} }
if isGitHubHost(remote.BaseURL) { if IsGitHubHost(remote.BaseURL) {
if c := githubauth.Server(); c != nil { if c := githubauth.Server(); c != nil {
tok, err := c.Token(ctx) tok, err := c.Token(ctx)
if err != nil { if err != nil {
@@ -85,11 +85,11 @@ func (p *Provider) AuthHeaders(ctx context.Context, remote models.Remote) (http.
return h, nil 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 // accepts the server credential. objects.githubusercontent.com is deliberately
// excluded: release-asset downloads 302-redirect there with a pre-signed URL // excluded: release-asset downloads 302-redirect there with a pre-signed URL
// that must not carry an Authorization header. // that must not carry an Authorization header.
func isGitHubHost(rawURL string) bool { func IsGitHubHost(rawURL string) bool {
u, err := url.Parse(rawURL) u, err := url.Parse(rawURL)
if err != nil { if err != nil {
return false 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" "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 type Mutability int
const ( const (
+43 -33
View File
@@ -29,7 +29,7 @@ const filesPrefix = "v3/files/"
// tarball basename that `puppet module build` produces. // 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.-]+)?)$`) 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)) m := releaseRe.FindStringSubmatch(strings.ToLower(s))
if m == nil { if m == nil {
return "", "", false return "", "", false
@@ -37,7 +37,12 @@ func parseRelease(s string) (slug, version string, ok bool) {
return m[1] + "-" + m[2], m[3], true 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, "/", "-")) return strings.ToLower(strings.ReplaceAll(s, "/", "-"))
} }
@@ -45,7 +50,7 @@ func normalizeSlug(s string) string {
// resolve straight to the stored file. // resolve straight to the stored file.
func (p *Provider) ValidateUpload(filePath string) (storagePath, contentType string, err error) { func (p *Provider) ValidateUpload(filePath string) (storagePath, contentType string, err error) {
filename := strings.ToLower(path.Base(path.Clean("/" + filePath))) 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 "", "", fmt.Errorf("file must be named <owner>-<module>-<version>.tar.gz")
} }
return filesPrefix + filename, "application/gzip", nil 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) slog.Error("puppet metadata: read failed", "repo", repoName, "path", storagePath, "error", err)
return 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 { 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) slog.Error("puppet metadata: metadata.json does not match filename", "repo", repoName, "path", storagePath, "name", meta.Slug, "version", meta.Version)
return return
@@ -158,7 +163,7 @@ func parseModule(r io.Reader) (*provider.PuppetMetadata, error) {
if md.Name == "" || md.Version == "" { if md.Name == "" || md.Version == "" {
return nil, errors.New("metadata.json missing name or 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": case clean == "v3/releases":
serveReleaseList(w, r, list) serveReleaseList(w, r, list)
case strings.HasPrefix(clean, "v3/releases/"): 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 { if !ok {
notFound(w) NotFound(w)
return true return true
} }
metas, err := list(slug) metas, err := list(slug)
@@ -193,23 +198,23 @@ func (p *Provider) ServeLocalIndex(w http.ResponseWriter, r *http.Request, files
} }
for _, m := range metas { for _, m := range metas {
if m.Version == version { if m.Version == version {
writeJSON(w, releaseObject(m)) WriteJSON(w, releaseObject(m))
return true return true
} }
} }
notFound(w) NotFound(w)
case strings.HasPrefix(clean, "v3/modules/"): case strings.HasPrefix(clean, "v3/modules/"):
slug := normalizeSlug(strings.TrimPrefix(clean, "v3/modules/")) slug := NormalizeSlug(strings.TrimPrefix(clean, "v3/modules/"))
metas, err := list(slug) metas, err := list(slug)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return true return true
} }
if len(metas) == 0 { if len(metas) == 0 {
notFound(w) NotFound(w)
return true return true
} }
writeJSON(w, moduleObject(metas)) WriteJSON(w, moduleObject(metas))
default: default:
return false 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)) { func serveReleaseList(w http.ResponseWriter, r *http.Request, list func(string) ([]provider.PuppetMetadata, error)) {
q := r.URL.Query() 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")) limit, err := strconv.Atoi(q.Get("limit"))
if err != nil || limit <= 0 { if err != nil || limit <= 0 {
limit = 20 limit = 20
@@ -232,20 +252,10 @@ func serveReleaseList(w http.ResponseWriter, r *http.Request, list func(string)
if err != nil || offset < 0 { if err != nil || offset < 0 {
offset = 0 offset = 0
} }
offset = min(offset, len(releases))
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))
}
page := func(off int) any { page := func(off int) any {
if off < 0 || off >= len(metas) && off != 0 { if off < 0 || off >= len(releases) && off != 0 {
return nil return nil
} }
v := url.Values{"limit": {strconv.Itoa(limit)}, "offset": {strconv.Itoa(off)}} 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 { if offset > 0 {
previous = page(max(offset-limit, 0)) previous = page(max(offset-limit, 0))
} }
writeJSON(w, map[string]any{ return map[string]any{
"pagination": map[string]any{ "pagination": map[string]any{
"limit": limit, "limit": limit,
"offset": offset, "offset": offset,
@@ -266,10 +276,10 @@ func serveReleaseList(w http.ResponseWriter, r *http.Request, list func(string)
"previous": previous, "previous": previous,
"current": page(offset), "current": page(offset),
"next": page(offset + limit), "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 { func owner(slug string) map[string]any {
@@ -329,7 +339,7 @@ func moduleObject(metas []provider.PuppetMetadata) map[string]any {
return obj return obj
} }
func writeJSON(w http.ResponseWriter, v any) { func WriteJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
enc := json.NewEncoder(w) enc := json.NewEncoder(w)
@@ -337,7 +347,7 @@ func writeJSON(w http.ResponseWriter, v any) {
_ = enc.Encode(v) _ = enc.Encode(v)
} }
func notFound(w http.ResponseWriter) { func NotFound(w http.ResponseWriter) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
_, _ = io.WriteString(w, `{"message":"404 Not Found","errors":["The requested resource could not be found"]}`) _, _ = 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 { if c := strings.Compare(a.Slug, b.Slug); c != 0 {
return c 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, "+") a, _, _ = strings.Cut(a, "+")
b, _, _ = strings.Cut(b, "+") b, _, _ = strings.Cut(b, "+")
aCore, aPre, aHasPre := strings.Cut(a, "-") aCore, aPre, aHasPre := strings.Cut(a, "-")
+2 -2
View File
@@ -291,11 +291,11 @@ func TestFilesFallThrough(t *testing.T) {
func TestCompareSemver(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"} 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++ { 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]) 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") t.Error("build metadata must not affect precedence")
} }
} }
+27
View File
@@ -3,6 +3,7 @@ package puppet
import ( import (
"context" "context"
"net/http" "net/http"
"strconv"
"strings" "strings"
"git.unkin.net/unkin/artifactapi/internal/auth" "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, "/") 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) RewritesResponse() {}
func (p *Provider) RewriteResponse(body []byte, remote models.Remote, proxyBaseURL string) ([]byte, error) { 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") 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)
}
}
}
+13 -12
View File
@@ -343,6 +343,9 @@ func (p *GitHubProvider) scanWithState(ctx context.Context, remote models.Remote
return newEtag, false, err 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{} seen := map[string]bool{}
for _, rel := range releases { for _, rel := range releases {
if rel.Draft { if rel.Draft {
@@ -373,10 +376,14 @@ func (p *GitHubProvider) scanWithState(ctx context.Context, remote models.Remote
meta, err := p.deriveAsset(ctx, remote, asset, fp) meta, err := p.deriveAsset(ctx, remote, asset, fp)
if err != nil { if err != nil {
slog.Warn("github_rpm: derive asset failed", "remote", remote.Name, "asset", asset.Name, "error", err) 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 continue
} }
if err := store.InsertRPMMetadata(ctx, meta); err != nil { if err := store.InsertRPMMetadata(ctx, meta); err != nil {
slog.Error("github_rpm: insert metadata failed", "remote", remote.Name, "asset", asset.Name, "error", err) 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 continue
} }
slog.Info("github_rpm: derived asset", "remote", remote.Name, "name", meta.Name, "version", meta.Version, "arch", meta.Arch) slog.Info("github_rpm: derived asset", "remote", remote.Name, "name", meta.Name, "version", meta.Version, "arch", meta.Arch)
@@ -388,7 +395,7 @@ func (p *GitHubProvider) scanWithState(ctx context.Context, remote models.Remote
_ = store.DeleteRPMMetadata(ctx, remote.Name, fp) _ = store.DeleteRPMMetadata(ctx, remote.Name, fp)
} }
} }
return newEtag, true, nil return newEtag, true, errors.Join(failed...)
} }
type ghRelease struct { type ghRelease struct {
@@ -569,12 +576,12 @@ func (p *GitHubProvider) fetchHeader(ctx context.Context, remote models.Remote,
} }
continue 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 // 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) { 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) req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
if err != nil { if err != nil {
@@ -596,15 +603,9 @@ func (p *GitHubProvider) rangeGet(ctx context.Context, remote models.Remote, dow
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent { 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))
} }
return provider.ReadPrefix(resp, n)
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
} }
func (p *GitHubProvider) computeSHA256(ctx context.Context, remote models.Remote, downloadURL string) (string, error) { func (p *GitHubProvider) computeSHA256(ctx context.Context, remote models.Remote, downloadURL string) (string, error) {
@@ -627,7 +628,7 @@ func (p *GitHubProvider) computeSHA256(ctx context.Context, remote models.Remote
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { 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() h := sha256.New()
+34 -6
View File
@@ -1,11 +1,13 @@
package rpm package rpm
import ( import (
"cmp"
"compress/gzip" "compress/gzip"
"context" "context"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -24,15 +26,22 @@ import (
// fakeStore is an in-memory provider.RemoteMetadataStore keyed by file_path, // fakeStore is an in-memory provider.RemoteMetadataStore keyed by file_path,
// mirroring the (repo_name, file_path) uniqueness of the real table. // mirroring the (repo_name, file_path) uniqueness of the real table.
type fakeStore struct { type fakeStore struct {
mu sync.Mutex mu sync.Mutex
rows map[string]provider.RPMMetadata 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 { func (f *fakeStore) InsertRPMMetadata(_ context.Context, m *provider.RPMMetadata) error {
f.mu.Lock() f.mu.Lock()
defer f.mu.Unlock() 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 { if _, ok := f.rows[m.FilePath]; ok {
return nil // ON CONFLICT DO NOTHING return nil // ON CONFLICT DO NOTHING
} }
@@ -79,15 +88,20 @@ type githubFixture struct {
releaseAuth string // Authorization header seen on the last releases request releaseAuth string // Authorization header seen on the last releases request
assetAuth string // Authorization header seen on the last asset request assetAuth string // Authorization header seen on the last asset request
failStatus int // when set, the releases list answers this status (rate-limit style) 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 mu sync.Mutex
} }
func newGitHubFixture(t *testing.T, withDigest bool) *githubFixture { func newGitHubFixture(t *testing.T, withDigest bool) *githubFixture {
t.Helper() t.Helper()
f := &githubFixture{ f := &githubFixture{
rpmBytes: map[string][]byte{}, rpmBytes: map[string][]byte{},
rangeHit: map[string]int{}, rangeHit: map[string]int{},
fullHit: 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") 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") rng := r.Header.Get("Range")
f.mu.Lock() 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") f.assetAuth = r.Header.Get("Authorization")
if rng != "" { if rng != "" {
f.rangeHit[name]++ f.rangeHit[name]++
@@ -165,6 +190,9 @@ func newGitHubFixture(t *testing.T, withDigest bool) *githubFixture {
end = len(body) - 1 end = len(body) - 1
} }
w.Header().Set("Content-Range", fmt.Sprintf("bytes 0-%d/%d", end, len(body))) 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.Header().Set("Content-Length", strconv.Itoa(end+1))
w.WriteHeader(http.StatusPartialContent) w.WriteHeader(http.StatusPartialContent)
w.Write(body[:end+1]) w.Write(body[:end+1])
+14 -12
View File
@@ -21,7 +21,7 @@ const (
// scan never loses its lease, while a crashed replica's lease still expires. // scan never loses its lease, while a crashed replica's lease still expires.
syncLeaseDuration = 15 * time.Minute syncLeaseDuration = 15 * time.Minute
// defaultSyncFreshness is the periodic re-check interval used when a remote's // defaultSyncFreshness is the periodic re-check interval used when a remote's
// mutable_ttl is unset. // sync_interval and mutable_ttl are unset.
defaultSyncFreshness = 5 * time.Minute defaultSyncFreshness = 5 * time.Minute
// jobQueueDepth bounds the pending work queue; enqueues past it are dropped // jobQueueDepth bounds the pending work queue; enqueues past it are dropped
// (a later poll re-enqueues), never blocking the caller. // (a later poll re-enqueues), never blocking the caller.
@@ -34,7 +34,7 @@ const (
type SyncStore interface { type SyncStore interface {
provider.RemoteMetadataStore provider.RemoteMetadataStore
ListGitHubRPMRemotes(ctx context.Context) ([]models.Remote, error) ListGitHubRPMRemotes(ctx context.Context) ([]models.Remote, error)
ClaimGitHubSyncLease(ctx context.Context, remoteName, owner string, freshness, lease time.Duration) (claimed bool, etag string, err error) ClaimGitHubSyncLease(ctx context.Context, remoteName, owner string, freshness, lease, interval, phase time.Duration) (claimed bool, etag string, err error)
ReleaseGitHubSyncLease(ctx context.Context, remoteName, owner string, res provider.SyncResult) error ReleaseGitHubSyncLease(ctx context.Context, remoteName, owner string, res provider.SyncResult) error
} }
@@ -43,7 +43,7 @@ type SyncConfig struct {
RatePerSec float64 // global GitHub request rate (req/s) RatePerSec float64 // global GitHub request rate (req/s)
Burst int // token-bucket burst Burst int // token-bucket burst
Workers int // concurrent scan workers Workers int // concurrent scan workers
PollInterval time.Duration // base scheduler tick; per-remote cadence is mutable_ttl PollInterval time.Duration // base scheduler tick; per-remote cadence is sync_interval or mutable_ttl
} }
type syncJob struct { type syncJob struct {
@@ -139,9 +139,9 @@ func (s *Syncer) Run(ctx context.Context) {
} }
// schedule enqueues a periodic check for every github_rpm remote. The DB lease // schedule enqueues a periodic check for every github_rpm remote. The DB lease
// (claimed in the worker) enforces the per-remote mutable_ttl cadence and cross // (claimed in the worker) enforces the per-remote sync_interval (else
// replica coordination, so enqueuing every tick is cheap: a not-yet-due remote // mutable_ttl) cadence and cross replica coordination, so enqueuing every tick
// simply fails to claim and is skipped. // is cheap: a not-yet-due remote simply fails to claim and is skipped.
func (s *Syncer) schedule(ctx context.Context) { func (s *Syncer) schedule(ctx context.Context) {
remotes, err := s.store.ListGitHubRPMRemotes(ctx) remotes, err := s.store.ListGitHubRPMRemotes(ctx)
if err != nil { if err != nil {
@@ -155,8 +155,10 @@ func (s *Syncer) schedule(ctx context.Context) {
// EnqueuePrime queues an immediate background prime for a freshly created // EnqueuePrime queues an immediate background prime for a freshly created
// remote so its metadata is derived without blocking the create call. // remote so its metadata is derived without blocking the create call.
// A sync_interval remote skips it and syncs in its first slot, so remotes
// created together do not burst.
func (s *Syncer) EnqueuePrime(remote models.Remote) { func (s *Syncer) EnqueuePrime(remote models.Remote) {
if s == nil { if s == nil || remote.SyncInterval > 0 {
return return
} }
s.enqueue(remote, true) s.enqueue(remote, true)
@@ -205,16 +207,16 @@ func (s *Syncer) process(ctx context.Context, job syncJob) {
s.mu.Unlock() s.mu.Unlock()
}() }()
ttl := time.Duration(job.remote.MutableTTL) * time.Second ttl := job.remote.SyncPeriod(defaultSyncFreshness)
if ttl <= 0 {
ttl = defaultSyncFreshness
}
freshness := ttl freshness := ttl
var interval, phase time.Duration
if job.prime { if job.prime {
freshness = 0 // prime ignores the recency gate but still respects a live lease freshness = 0 // prime ignores the recency gate but still respects a live lease
} else {
interval, phase = job.remote.SyncSchedule()
} }
claimed, etag, err := s.store.ClaimGitHubSyncLease(ctx, job.remote.Name, s.owner, freshness, syncLeaseDuration) claimed, etag, err := s.store.ClaimGitHubSyncLease(ctx, job.remote.Name, s.owner, freshness, syncLeaseDuration, interval, phase)
if err != nil { if err != nil {
slog.Error("github_rpm syncer: claim lease", "remote", job.remote.Name, "error", err) slog.Error("github_rpm syncer: claim lease", "remote", job.remote.Name, "error", err)
return return
+113 -2
View File
@@ -29,6 +29,10 @@ type fakeSyncStore struct {
etags map[string]string etags map[string]string
retryAt map[string]time.Time retryAt map[string]time.Time
lastResult provider.SyncResult lastResult provider.SyncResult
freshness time.Duration
interval time.Duration
phase time.Duration
attempts int
} }
func newFakeSyncStore() *fakeSyncStore { func newFakeSyncStore() *fakeSyncStore {
@@ -48,9 +52,11 @@ func (f *fakeSyncStore) ListGitHubRPMRemotes(_ context.Context) ([]models.Remote
return append([]models.Remote(nil), f.remotes...), nil return append([]models.Remote(nil), f.remotes...), nil
} }
func (f *fakeSyncStore) ClaimGitHubSyncLease(_ context.Context, name, owner string, freshness, lease time.Duration) (bool, string, error) { func (f *fakeSyncStore) ClaimGitHubSyncLease(_ context.Context, name, owner string, freshness, lease, interval, phase time.Duration) (bool, string, error) {
f.mu.Lock() f.mu.Lock()
defer f.mu.Unlock() defer f.mu.Unlock()
f.freshness, f.interval, f.phase = freshness, interval, phase
f.attempts++
now := time.Now() now := time.Now()
ls, hasLS := f.lastSynced[name] ls, hasLS := f.lastSynced[name]
exp, hasExp := f.leaseExp[name] exp, hasExp := f.leaseExp[name]
@@ -230,7 +236,7 @@ func TestSyncerLeasePreventsSecondReplica(t *testing.T) {
remote := fx.remote() remote := fx.remote()
// Replica 1 holds the lease. // Replica 1 holds the lease.
claimed, _, err := store.ClaimGitHubSyncLease(context.Background(), remote.Name, "replica-1", time.Duration(remote.MutableTTL)*time.Second, syncLeaseDuration) claimed, _, err := store.ClaimGitHubSyncLease(context.Background(), remote.Name, "replica-1", time.Duration(remote.MutableTTL)*time.Second, syncLeaseDuration, 0, 0)
if err != nil || !claimed { if err != nil || !claimed {
t.Fatalf("replica-1 claim: claimed=%v err=%v", claimed, err) t.Fatalf("replica-1 claim: claimed=%v err=%v", claimed, err)
} }
@@ -399,3 +405,108 @@ func TestSyncerFailedScanRetriesAfterBackoff(t *testing.T) {
t.Fatalf("recovery did not refresh metadata: %d rows", len(rows)) t.Fatalf("recovery did not refresh metadata: %d rows", len(rows))
} }
} }
// A transiently failed asset fails the scan: the ETag is not advanced, a retry
// is scheduled, and the retry against the unchanged release list derives it. An
// invalid package is skipped instead, since no retry can fix it.
func TestSyncerFailedAssetRetry(t *testing.T) {
for _, tc := range []struct {
name string
corrupt bool
failCode int
short bool
insert bool
wantRetry bool
}{
{name: "transient", wantRetry: true},
{name: "rate limited", failCode: http.StatusForbidden, wantRetry: true},
{name: "short read", short: true, wantRetry: true},
{name: "insert failure", insert: true, wantRetry: true},
{name: "invalid package", corrupt: true},
{name: "asset not found", failCode: http.StatusNotFound},
{name: "asset gone", failCode: http.StatusGone},
} {
t.Run(tc.name, func(t *testing.T) {
const other = "other-9-9.aarch64.rpm"
fx := newGitHubFixture(t, true)
fx.etag = `"v1"`
fx.rpmBytes[other] = testsupport.MinimalRPM("other", "9", "9", "aarch64")
store := newFakeSyncStore()
switch {
case tc.corrupt:
fx.rpmBytes[other] = []byte("not a package")
case tc.short:
fx.shortRead[other] = 1
case tc.insert:
store.insertFail["other"] = 1
case tc.failCode != 0 && !tc.wantRetry:
fx.failCode, fx.assetFail[other] = tc.failCode, 1<<30
default:
fx.failCode, fx.assetFail[other] = tc.failCode, 1
}
s := newSyncer(store, newTestProvider(), testSyncConfig())
remote := fx.remote()
bg := context.Background()
s.process(bg, syncJob{remote: remote, prime: true})
_, pending := store.retryAt[remote.Name]
if pending != tc.wantRetry {
t.Fatalf("retry pending=%v, want %v", pending, tc.wantRetry)
}
if _, saved := store.etags[remote.Name]; saved == tc.wantRetry {
t.Fatalf("etag saved=%v after first scan", saved)
}
if rows, _ := store.ListRPMMetadataEntries(bg, remote.Name); len(rows) != 1 {
t.Fatalf("healthy asset not served: %d rows", len(rows))
}
if !tc.wantRetry {
return
}
store.mu.Lock()
store.retryAt[remote.Name] = time.Now().Add(-time.Second)
store.mu.Unlock()
s.process(bg, syncJob{remote: remote})
if _, pending := store.retryAt[remote.Name]; pending || store.etags[remote.Name] != `"v1"` {
t.Fatalf("retry not recorded: pending=%v etag=%q", pending, store.etags[remote.Name])
}
if rows, _ := store.ListRPMMetadataEntries(bg, remote.Name); len(rows) != 2 {
t.Fatalf("retry did not derive the failed asset: %d rows", len(rows))
}
})
}
}
// 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)
}
}
+45
View File
@@ -3,8 +3,10 @@ package provider
import ( import (
"errors" "errors"
"fmt" "fmt"
"io"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"time" "time"
) )
@@ -43,6 +45,49 @@ func NewUpstreamStatusError(url string, resp *http.Response) *UpstreamStatusErro
return e return e
} }
// ErrInvalidPackage marks an asset whose bytes are not a valid package. It is
// permanent, so a scan skips the asset instead of failing and retrying.
var ErrInvalidPackage = errors.New("invalid package")
// AssetStatusError classifies a non-success asset download. A missing or
// unsatisfiable asset is permanent; anything else (403/429 rate limits, 5xx) is
// transient.
func AssetStatusError(url string, resp *http.Response) error {
switch resp.StatusCode {
case http.StatusNotFound, http.StatusGone, http.StatusRequestedRangeNotSatisfiable:
return fmt.Errorf("%w: %s: status %d", ErrInvalidPackage, url, resp.StatusCode)
}
return NewUpstreamStatusError(url, resp)
}
// ReadPrefix reads up to n bytes of a 200 or 206 asset response. full reports
// that the body is the whole object (per Content-Length or the Content-Range
// total, or a clean EOF before n when the total is unknown); a body ending before n bytes that is not the whole object is a
// transient short read.
func ReadPrefix(resp *http.Response, n int64) (body []byte, full bool, err error) {
body, err = io.ReadAll(io.LimitReader(resp.Body, n))
if err != nil {
return nil, false, err
}
total := resp.ContentLength
unknown := resp.StatusCode == http.StatusOK && total == -1
if resp.StatusCode == http.StatusPartialContent {
total = -1
if _, t, ok := strings.Cut(resp.Header.Get("Content-Range"), "/"); ok {
if v, perr := strconv.ParseInt(t, 10, 64); perr == nil {
total = v
}
unknown = t == "*"
}
}
// net/http errors on a premature close, so a clean EOF before n with an unknown total is the whole object.
full = total == int64(len(body)) || (unknown && int64(len(body)) < n)
if !full && int64(len(body)) < n {
return nil, false, fmt.Errorf("short read: got %d of %d bytes", len(body), n)
}
return body, full, nil
}
// SyncResult is a background scan's outcome, recorded when its sync lease is // SyncResult is a background scan's outcome, recorded when its sync lease is
// released. A failed scan keeps the prior sync time and ETag and schedules a // released. A failed scan keeps the prior sync time and ETag and schedules a
// retry after Backoff, doubled per consecutive failure up to MaxBackoff, and // retry after Backoff, doubled per consecutive failure up to MaxBackoff, and
+75
View File
@@ -3,7 +3,9 @@ package provider
import ( import (
"errors" "errors"
"fmt" "fmt"
"io"
"net/http" "net/http"
"net/http/httptest"
"strconv" "strconv"
"testing" "testing"
"time" "time"
@@ -61,3 +63,76 @@ func TestNewSyncResult(t *testing.T) {
t.Fatalf("wrapped hint not clamped to ttl: retry in %v", until) t.Fatalf("wrapped hint not clamped to ttl: retry in %v", until)
} }
} }
func TestAssetStatusErrorClassification(t *testing.T) {
for status, permanent := range map[int]bool{
http.StatusNotFound: true, http.StatusGone: true, http.StatusRequestedRangeNotSatisfiable: true,
http.StatusForbidden: false, http.StatusTooManyRequests: false, http.StatusInternalServerError: false, http.StatusBadGateway: false,
} {
err := AssetStatusError("u", &http.Response{StatusCode: status, Header: http.Header{}})
if errors.Is(err, ErrInvalidPackage) != permanent {
t.Errorf("status %d: permanent=%v, want %v", status, !permanent, permanent)
}
}
}
func TestReadPrefix(t *testing.T) {
cases := []struct {
name string
status int
cl int64
crange string
body string
n int64
wantFull bool
wantErr bool
}{
{name: "200 whole object", status: 200, cl: 5, body: "hello", n: 32, wantFull: true},
{name: "200 larger than n", status: 200, cl: 5, body: "hello", n: 4},
{name: "200 chunked shorter than n", status: 200, cl: -1, body: "hello", n: 32, wantFull: true},
{name: "200 chunked larger than n", status: 200, cl: -1, body: "hello", n: 4},
{name: "206 whole object", status: 206, crange: "bytes 0-4/5", body: "hello", n: 32, wantFull: true},
{name: "206 prefix", status: 206, crange: "bytes 0-3/100", body: "hell", n: 4},
{name: "206 short read", status: 206, crange: "bytes 0-31/100", body: "hello", n: 32, wantErr: true},
{name: "206 no content-range short", status: 206, body: "hello", n: 32, wantErr: true},
{name: "206 unknown total shorter than n", status: 206, cl: -1, crange: "bytes 0-4/*", body: "hello", n: 32, wantFull: true},
{name: "206 unknown total prefix", status: 206, cl: -1, crange: "bytes 0-3/*", body: "hell", n: 4},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if tc.cl > 0 {
w.Header().Set("Content-Length", strconv.FormatInt(tc.cl, 10))
}
if tc.crange != "" {
w.Header().Set("Content-Range", tc.crange)
}
w.WriteHeader(tc.status)
_, _ = io.WriteString(w, tc.body)
w.(http.Flusher).Flush()
}))
defer srv.Close()
resp, err := http.Get(srv.URL)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if tc.cl == -1 && resp.ContentLength != -1 {
t.Fatalf("response not chunked: ContentLength=%d", resp.ContentLength)
}
body, full, err := ReadPrefix(resp, tc.n)
if (err != nil) != tc.wantErr {
t.Fatalf("err=%v, wantErr=%v", err, tc.wantErr)
}
if err != nil {
if errors.Is(err, ErrInvalidPackage) {
t.Fatalf("short read must be transient: %v", err)
}
return
}
if full != tc.wantFull || int64(len(body)) > tc.n {
t.Fatalf("full=%v len=%d, want full=%v", full, len(body), tc.wantFull)
}
})
}
}
+309
View File
@@ -0,0 +1,309 @@
// Package vcs caches source archives and release assets from GitHub or Gitea,
// keyed by ref: tags, commits and release assets are immutable, and branch
// archives redirect to the archive of the commit the branch currently points at.
package vcs
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"sync"
"time"
"golang.org/x/sync/singleflight"
"git.unkin.net/unkin/artifactapi/internal/provider"
"git.unkin.net/unkin/artifactapi/internal/provider/generic"
"git.unkin.net/unkin/artifactapi/internal/proxy"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
func init() {
provider.Register(New())
}
type kind int
const (
tagArchive kind = iota + 1
branchArchive
commitArchive
releaseAsset
latestReleaseAsset
)
// target is a parsed vcs request path.
type target struct {
Kind kind
Owner, Repo string
Name string // tag, branch, commit or asset
Ext string
}
var (
segmentRe = regexp.MustCompile(`^[A-Za-z0-9._+-]+$`)
commitRe = regexp.MustCompile(`^[0-9a-f]{40}$`)
)
var errNotFound = errors.New("ref not found")
const (
maxRefs = 4096
negativeTTL = time.Minute
)
// Provider embeds generic for upstream URLs, content types and GitHub/Basic auth.
type Provider struct {
generic.Provider
client *http.Client
githubAPI string
group singleflight.Group
mu sync.Mutex
refs map[string]map[string]resolved
}
// resolved is a branch lookup; an empty commit records a missing branch.
type resolved struct {
commit string
at time.Time
}
func New() *Provider {
return &Provider{
client: &http.Client{Timeout: 30 * time.Second},
githubAPI: "https://api.github.com",
refs: map[string]map[string]resolved{},
}
}
func (p *Provider) Type() models.PackageType { return models.PackageVCS }
func (p *Provider) Classify(path string) provider.Mutability {
if ref, ok := parsePath(path); ok && (ref.Kind == branchArchive || ref.Kind == latestReleaseAsset) {
return provider.Mutable
}
return provider.Immutable
}
// parsePath accepts only the archive and release-download shapes, rejecting
// any segment that could escape owner/repo or a ref.
func parsePath(path string) (target, bool) {
parts := strings.SplitN(strings.TrimPrefix(path, "/"), "/", 3)
if len(parts) != 3 || !validSegment(parts[0]) || !validSegment(parts[1]) {
return target{}, false
}
ref := target{Owner: parts[0], Repo: parts[1]}
rest := parts[2]
switch {
case strings.HasPrefix(rest, "archive/"):
name, ext, ok := archiveName(strings.TrimPrefix(rest, "archive/"))
if !ok {
return target{}, false
}
ref.Ext = ext
switch {
case strings.HasPrefix(name, "refs/tags/"):
ref.Kind, ref.Name = tagArchive, strings.TrimPrefix(name, "refs/tags/")
case strings.HasPrefix(name, "refs/heads/"):
ref.Kind, ref.Name = branchArchive, strings.TrimPrefix(name, "refs/heads/")
case commitRe.MatchString(name):
return target{Kind: commitArchive, Owner: ref.Owner, Repo: ref.Repo, Name: name, Ext: ext}, true
default:
return target{}, false
}
return ref, validRefName(ref.Name)
case strings.HasPrefix(rest, "releases/latest/download/"):
ref.Kind, ref.Name = latestReleaseAsset, strings.TrimPrefix(rest, "releases/latest/download/")
return ref, validSegment(ref.Name)
case strings.HasPrefix(rest, "releases/download/"):
tagAsset := strings.TrimPrefix(rest, "releases/download/")
i := strings.LastIndex(tagAsset, "/")
if i < 0 {
return target{}, false
}
ref.Kind, ref.Name = releaseAsset, tagAsset[i+1:]
return ref, validRefName(tagAsset[:i]) && validSegment(ref.Name)
}
return target{}, false
}
func archiveName(s string) (string, string, bool) {
for _, ext := range []string{".tar.gz", ".zip"} {
if name, ok := strings.CutSuffix(s, ext); ok && name != "" {
return name, ext, true
}
}
return "", "", false
}
func validSegment(s string) bool {
return segmentRe.MatchString(s) && s != "." && s != ".." && !strings.Contains(s, "..")
}
func validRefName(s string) bool {
for _, seg := range strings.Split(s, "/") {
if !validSegment(seg) || strings.HasPrefix(seg, ".") {
return false
}
}
return true
}
// ServeRemote rejects unsupported paths and redirects branch archives to the
// immutable archive of the branch's current commit; everything else is proxied.
func (p *Provider) ServeRemote(w http.ResponseWriter, r *http.Request, remote models.Remote, path, proxyBaseURL string, _ provider.RemoteMetadataStore) bool {
ref, ok := parsePath(path)
if !ok {
http.Error(w, "unsupported vcs path", http.StatusNotFound)
return true
}
if ref.Kind != branchArchive {
return false
}
classifier := proxy.NewClassifier(p)
if classifier.Classify(remote, path) == proxy.ClassDenied {
http.Error(w, "access denied", http.StatusForbidden)
return true
}
commit, err := p.resolve(r.Context(), remote, ref)
var se *provider.UpstreamStatusError
switch {
case errors.Is(err, errNotFound):
http.Error(w, "branch not found", http.StatusNotFound)
return true
case errors.As(err, &se) && (se.Status == http.StatusTooManyRequests || se.Status == http.StatusForbidden && !se.RetryAt.IsZero()):
secs := 60
if !se.RetryAt.IsZero() {
secs = max(1, int(time.Until(se.RetryAt).Seconds()+1))
}
w.Header().Set("Retry-After", strconv.Itoa(secs))
http.Error(w, "upstream rate limited", http.StatusServiceUnavailable)
return true
case err != nil:
http.Error(w, "bad gateway", http.StatusBadGateway)
return true
}
target := ref.Owner + "/" + ref.Repo + "/archive/" + commit + ref.Ext
if classifier.Classify(remote, target) == proxy.ClassDenied {
http.Error(w, "access denied", http.StatusForbidden)
return true
}
http.Redirect(w, r, strings.TrimRight(proxyBaseURL, "/")+provider.RemoteMountPath+remote.Name+"/"+target, http.StatusFound)
return true
}
// resolve maps a branch to its commit, reusing the answer for mutable_ttl (a
// missing branch for at least negativeTTL) and, with stale_on_error, past an
// upstream failure. Concurrent lookups of one branch share a single API call.
func (p *Provider) resolve(ctx context.Context, remote models.Remote, ref target) (string, error) {
key := ref.Owner + "/" + ref.Repo + "\x00" + ref.Name
ttl := time.Duration(remote.MutableTTL) * time.Second
p.mu.Lock()
prev, ok := p.refs[remote.Name][key]
p.mu.Unlock()
if ok && prev.commit != "" && time.Since(prev.at) < ttl {
return prev.commit, nil
}
if ok && prev.commit == "" && time.Since(prev.at) < max(ttl, negativeTTL) {
return "", errNotFound
}
v, err, _ := p.group.Do(remote.Name+"\x00"+key, func() (any, error) {
commit, err := p.lookup(context.WithoutCancel(ctx), remote, ref)
if err == nil || errors.Is(err, errNotFound) {
p.store(remote.Name, key, commit)
}
return commit, err
})
if err != nil {
if ok && prev.commit != "" && remote.StaleOnError && !errors.Is(err, errNotFound) {
return prev.commit, nil
}
return "", err
}
return v.(string), nil
}
// store records a lookup, evicting the remote's oldest entry once it holds maxRefs.
func (p *Provider) store(remote, key, commit string) {
p.mu.Lock()
defer p.mu.Unlock()
m := p.refs[remote]
if m == nil {
m = map[string]resolved{}
p.refs[remote] = m
}
if _, ok := m[key]; !ok && len(m) >= maxRefs {
var oldest string
for k, e := range m {
if oldest == "" || e.at.Before(m[oldest].at) {
oldest = k
}
}
delete(m, oldest)
}
m[key] = resolved{commit: commit, at: time.Now()}
}
func (p *Provider) lookup(ctx context.Context, remote models.Remote, ref target) (string, error) {
segs := strings.Split(ref.Name, "/")
for i, s := range segs {
segs[i] = url.PathEscape(s)
}
branch := strings.Join(segs, "/")
github := generic.IsGitHubHost(remote.BaseURL)
api := strings.TrimRight(remote.BaseURL, "/") + "/api/v1/repos/" + ref.Owner + "/" + ref.Repo + "/branches/" + branch
if github {
api = p.githubAPI + "/repos/" + ref.Owner + "/" + ref.Repo + "/git/ref/heads/" + branch
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, api, nil)
if err != nil {
return "", err
}
h, err := p.AuthHeaders(ctx, remote)
if err != nil {
return "", err
}
req.Header = h
if github {
req.Header.Set("Accept", "application/vnd.github+json")
}
resp, err := p.client.Do(req)
if err != nil {
return "", err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusUnprocessableEntity {
return "", errNotFound
}
if resp.StatusCode != http.StatusOK {
return "", provider.NewUpstreamStatusError(api, resp)
}
var b struct {
Commit struct {
ID string `json:"id"`
} `json:"commit"`
Object struct {
SHA string `json:"sha"`
} `json:"object"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&b); err != nil {
return "", err
}
commit := b.Commit.ID
if github {
commit = b.Object.SHA
}
if !commitRe.MatchString(commit) {
return "", fmt.Errorf("branch lookup %s: unexpected commit %q", api, commit)
}
return commit, nil
}
+378
View File
@@ -0,0 +1,378 @@
package vcs
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"git.unkin.net/unkin/artifactapi/internal/githubauth"
"git.unkin.net/unkin/artifactapi/internal/provider"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
const (
shaA = "4f2cfe92e3499f8581bf39db77e0a1431fa7b55c"
shaB = "7fa5434dbc083c853eef435d1915020345219d2f"
)
func TestParsePath(t *testing.T) {
tests := []struct {
path string
ok bool
kind kind
name string
}{
{"cli/cli/archive/refs/tags/v2.60.0.tar.gz", true, tagArchive, "v2.60.0"},
{"/cli/cli/archive/refs/tags/v2.60.0.zip", true, tagArchive, "v2.60.0"},
{"o/r/archive/refs/tags/release/1.0.tar.gz", true, tagArchive, "release/1.0"},
{"o/r/archive/refs/heads/feature/x.tar.gz", true, branchArchive, "feature/x"},
{"o/r/archive/" + shaA + ".tar.gz", true, commitArchive, shaA},
{"o/r/releases/download/v1.0/tool_linux_amd64.tar.gz", true, releaseAsset, "tool_linux_amd64.tar.gz"},
{"o/r/releases/latest/download/tool.zip", true, latestReleaseAsset, "tool.zip"},
{"o/r/archive/main.tar.gz", false, 0, ""},
{"o/r/archive/" + shaA[:7] + ".tar.gz", false, 0, ""},
{"o/r/archive/refs/tags/v1.tar.bz2", false, 0, ""},
{"o/r/archive/refs/tags/.tar.gz", false, 0, ""},
{"../r/archive/refs/tags/v1.tar.gz", false, 0, ""},
{"o/../archive/refs/tags/v1.tar.gz", false, 0, ""},
{"o/r/archive/refs/tags/../../../etc/passwd.tar.gz", false, 0, ""},
{"o/r/archive/refs/heads/a//b.tar.gz", false, 0, ""},
{"o/r/archive/refs/heads/.hidden.tar.gz", false, 0, ""},
{"o/r/archive/refs/heads/a..b.tar.gz", false, 0, ""},
{"o/r/archive/refs/tags/v1%2f.tar.gz", false, 0, ""},
{"o/r/releases/download/v1/../x", false, 0, ""},
{"o/r/releases/download/asset", false, 0, ""},
{"o/r/raw/main/README.md", false, 0, ""},
{"o/r", false, 0, ""},
}
for _, tt := range tests {
ref, ok := parsePath(tt.path)
if ok != tt.ok || (ok && (ref.Kind != tt.kind || ref.Name != tt.name)) {
t.Errorf("parsePath(%q) = %+v, %v; want kind %v name %q ok %v", tt.path, ref, ok, tt.kind, tt.name, tt.ok)
}
}
}
func TestClassify(t *testing.T) {
p := New()
for path, want := range map[string]provider.Mutability{
"o/r/archive/refs/tags/v1.tar.gz": provider.Immutable,
"o/r/archive/" + shaA + ".zip": provider.Immutable,
"o/r/releases/download/v1/a.bin": provider.Immutable,
"o/r/archive/refs/heads/main.tar.gz": provider.Mutable,
"o/r/releases/latest/download/webui.zip": provider.Mutable,
} {
if got := p.Classify(path); got != want {
t.Errorf("Classify(%q) = %v, want %v", path, got, want)
}
}
}
type upstream struct {
srv *httptest.Server
hits atomic.Int32
commit atomic.Value
status atomic.Int32
header atomic.Value
accept atomic.Value
auth atomic.Value
path atomic.Value
gate chan struct{}
}
func newUpstream(t *testing.T, gitea bool) *upstream {
u := &upstream{}
u.commit.Store(shaA)
u.status.Store(http.StatusOK)
u.header.Store(http.Header{})
u.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u.hits.Add(1)
if u.gate != nil {
<-u.gate
}
u.accept.Store(r.Header.Get("Accept"))
u.auth.Store(r.Header.Get("Authorization"))
u.path.Store(r.URL.EscapedPath())
for k, v := range u.header.Load().(http.Header) {
w.Header()[k] = v
}
if s := int(u.status.Load()); s != http.StatusOK {
w.WriteHeader(s)
return
}
if gitea {
_, _ = w.Write([]byte(`{"name":"main","commit":{"id":"` + u.commit.Load().(string) + `"}}`))
return
}
_, _ = w.Write([]byte(`{"ref":"refs/heads/main","object":{"sha":"` + u.commit.Load().(string) + `","type":"commit"}}`))
}))
t.Cleanup(u.srv.Close)
return u
}
func serve(p *Provider, remote models.Remote, path string) (*httptest.ResponseRecorder, bool) {
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/api/v1/remote/"+remote.Name+"/"+path, nil)
return w, p.ServeRemote(w, r, remote, path, "http://proxy", nil)
}
// age backdates every cached lookup of a remote by d.
func age(p *Provider, remote string, d time.Duration) {
p.mu.Lock()
defer p.mu.Unlock()
for k, e := range p.refs[remote] {
e.at = e.at.Add(-d)
p.refs[remote][k] = e
}
}
type staticCred string
func (s staticCred) Token(context.Context) (string, error) { return string(s), nil }
func TestServeRemoteGiteaBranchRedirectsToCommit(t *testing.T) {
u := newUpstream(t, true)
p := New()
remote := models.Remote{Name: "gitea", BaseURL: u.srv.URL, MutableTTL: 3600, Username: "bot", Password: "pw"}
w, handled := serve(p, remote, "unkin/app/archive/refs/heads/feature/x.zip")
if !handled || w.Code != http.StatusFound {
t.Fatalf("handled=%v code=%d", handled, w.Code)
}
if got, want := w.Header().Get("Location"), "http://proxy/api/v1/remote/gitea/unkin/app/archive/"+shaA+".zip"; got != want {
t.Errorf("Location = %q, want %q", got, want)
}
if got := u.path.Load(); got != "/api/v1/repos/unkin/app/branches/feature/x" {
t.Errorf("api path = %q", got)
}
if got := u.auth.Load(); got != "Basic Ym90OnB3" {
t.Errorf("Authorization = %q, want remote basic auth", got)
}
}
func TestServeRemoteGitHubBranchUsesAPI(t *testing.T) {
githubauth.SetServer(staticCred("ghs_server"))
t.Cleanup(func() { githubauth.SetServer(nil) })
u := newUpstream(t, false)
p := New()
p.githubAPI = u.srv.URL
for _, base := range []string{"https://github.com", "https://www.github.com/", "https://api.github.com"} {
remote := models.Remote{Name: base, BaseURL: base, MutableTTL: 3600}
w, _ := serve(p, remote, "cli/cli/archive/refs/heads/feature/x.tar.gz")
if !strings.HasSuffix(w.Header().Get("Location"), "/cli/cli/archive/"+shaA+".tar.gz") {
t.Fatalf("%s: Location = %q", base, w.Header().Get("Location"))
}
if u.path.Load() != "/repos/cli/cli/git/ref/heads/feature/x" || u.accept.Load() != "application/vnd.github+json" {
t.Errorf("%s: api path %q accept %q", base, u.path.Load(), u.accept.Load())
}
if got := u.auth.Load(); got != "Bearer ghs_server" {
t.Errorf("%s: Authorization = %q, want server credential", base, got)
}
}
}
func TestServeRemoteBranchResolutionHonoursMutableTTL(t *testing.T) {
u := newUpstream(t, true)
p := New()
remote := models.Remote{Name: "g", BaseURL: u.srv.URL, MutableTTL: 600}
path := "o/r/archive/refs/heads/main.tar.gz"
serve(p, remote, path)
u.commit.Store(shaB)
age(p, remote.Name, 599*time.Second)
w, _ := serve(p, remote, path)
if u.hits.Load() != 1 || !strings.Contains(w.Header().Get("Location"), shaA) {
t.Fatalf("within ttl: hits=%d location=%q", u.hits.Load(), w.Header().Get("Location"))
}
age(p, remote.Name, 2*time.Second)
w, _ = serve(p, remote, path)
if u.hits.Load() != 2 || !strings.Contains(w.Header().Get("Location"), shaB) {
t.Fatalf("past ttl: hits=%d location=%q", u.hits.Load(), w.Header().Get("Location"))
}
}
func TestServeRemoteBranchUpstreamFailure(t *testing.T) {
path := "o/r/archive/refs/heads/main.tar.gz"
for _, tc := range []struct {
name string
stale bool
want int
}{
{"stale", true, http.StatusFound},
{"strict", false, http.StatusBadGateway},
} {
t.Run(tc.name, func(t *testing.T) {
u := newUpstream(t, true)
p := New()
remote := models.Remote{Name: tc.name, BaseURL: u.srv.URL, StaleOnError: tc.stale}
serve(p, remote, path)
u.status.Store(http.StatusInternalServerError)
if w, _ := serve(p, remote, path); w.Code != tc.want {
t.Fatalf("code = %d, want %d", w.Code, tc.want)
}
})
}
}
func TestServeRemoteBranchRateLimited(t *testing.T) {
reset := strconv.FormatInt(time.Now().Add(120*time.Second).Unix(), 10)
for _, tc := range []struct {
name string
status int
header http.Header
want int
retry [2]int
}{
{"primary 403", http.StatusForbidden, http.Header{"X-Ratelimit-Remaining": {"0"}, "X-Ratelimit-Reset": {reset}}, http.StatusServiceUnavailable, [2]int{100, 122}},
{"secondary 403", http.StatusForbidden, http.Header{"Retry-After": {"30"}}, http.StatusServiceUnavailable, [2]int{30, 31}},
{"429", http.StatusTooManyRequests, http.Header{"Retry-After": {"5"}}, http.StatusServiceUnavailable, [2]int{5, 6}},
{"429 no hint", http.StatusTooManyRequests, http.Header{}, http.StatusServiceUnavailable, [2]int{60, 60}},
{"plain 403", http.StatusForbidden, http.Header{}, http.StatusBadGateway, [2]int{}},
} {
t.Run(tc.name, func(t *testing.T) {
u := newUpstream(t, false)
u.status.Store(int32(tc.status))
u.header.Store(tc.header)
p := New()
p.githubAPI = u.srv.URL
w, _ := serve(p, models.Remote{Name: "gh", BaseURL: "https://github.com"}, "o/r/archive/refs/heads/main.tar.gz")
if w.Code != tc.want {
t.Fatalf("code = %d, want %d", w.Code, tc.want)
}
ra := w.Header().Get("Retry-After")
if tc.want != http.StatusServiceUnavailable {
if ra != "" {
t.Fatalf("Retry-After = %q on %d", ra, w.Code)
}
return
}
if n, err := strconv.Atoi(ra); err != nil || n < tc.retry[0] || n > tc.retry[1] {
t.Fatalf("Retry-After = %q, want %d..%d", ra, tc.retry[0], tc.retry[1])
}
})
}
}
func TestServeRemoteBranchNotFoundIsCached(t *testing.T) {
u := newUpstream(t, false)
u.status.Store(http.StatusNotFound)
p := New()
p.githubAPI = u.srv.URL
remote := models.Remote{Name: "gh", BaseURL: "https://github.com"}
path := "o/r/archive/refs/heads/nope.tar.gz"
for range 3 {
if w, _ := serve(p, remote, path); w.Code != http.StatusNotFound {
t.Fatalf("code = %d, want 404", w.Code)
}
}
if u.hits.Load() != 1 {
t.Fatalf("hits = %d, want 1 within negative ttl", u.hits.Load())
}
u.status.Store(http.StatusOK)
age(p, remote.Name, negativeTTL)
if w, _ := serve(p, remote, path); w.Code != http.StatusFound || u.hits.Load() != 2 {
t.Fatalf("after negative ttl: code=%d hits=%d", w.Code, u.hits.Load())
}
}
func TestServeRemoteConcurrentLookupsShareOneCall(t *testing.T) {
u := newUpstream(t, true)
u.gate = make(chan struct{})
p := New()
remote := models.Remote{Name: "g", BaseURL: u.srv.URL, MutableTTL: 600}
var wg sync.WaitGroup
codes := make([]int, 20)
for i := range codes {
wg.Add(1)
go func() {
defer wg.Done()
w, _ := serve(p, remote, "o/r/archive/refs/heads/main.tar.gz")
codes[i] = w.Code
}()
}
for u.hits.Load() == 0 {
time.Sleep(time.Millisecond)
}
time.Sleep(50 * time.Millisecond)
close(u.gate)
wg.Wait()
if u.hits.Load() != 1 {
t.Fatalf("hits = %d, want 1", u.hits.Load())
}
for i, c := range codes {
if c != http.StatusFound {
t.Fatalf("request %d: code %d", i, c)
}
}
}
func TestStoreBoundsEntriesPerRemote(t *testing.T) {
p := New()
for i := range maxRefs + 10 {
p.store("a", fmt.Sprint(i), "")
}
p.store("b", "x", shaA)
if n := len(p.refs["a"]); n != maxRefs {
t.Fatalf("remote a holds %d entries, want %d", n, maxRefs)
}
if len(p.refs["b"]) != 1 {
t.Fatalf("remote b must keep its own budget")
}
if _, ok := p.refs["a"][fmt.Sprint(maxRefs+9)]; !ok {
t.Fatalf("newest entry evicted")
}
}
func TestServeRemoteRejectsBadCommit(t *testing.T) {
u := newUpstream(t, true)
u.commit.Store("../../etc")
w, _ := serve(New(), models.Remote{Name: "g", BaseURL: u.srv.URL}, "o/r/archive/refs/heads/main.tar.gz")
if w.Code != http.StatusBadGateway {
t.Fatalf("code = %d, want 502", w.Code)
}
}
func TestServeRemoteAllowlist(t *testing.T) {
u := newUpstream(t, true)
remote := models.Remote{Name: "g", BaseURL: u.srv.URL, Patterns: []string{"^allowed/repo/"}}
if w, _ := serve(New(), remote, "other/repo/archive/refs/heads/main.tar.gz"); w.Code != http.StatusForbidden {
t.Fatalf("code = %d, want 403", w.Code)
}
if u.hits.Load() != 0 {
t.Fatalf("denied branch must not hit the API")
}
}
func TestServeRemoteBlockedRedirectTarget(t *testing.T) {
u := newUpstream(t, true)
remote := models.Remote{Name: "g", BaseURL: u.srv.URL, Blocklist: []string{"/archive/" + shaA}}
w, _ := serve(New(), remote, "o/r/archive/refs/heads/main.tar.gz")
if w.Code != http.StatusForbidden || w.Header().Get("Location") != "" {
t.Fatalf("code = %d location %q, want 403 without redirect", w.Code, w.Header().Get("Location"))
}
}
func TestServeRemotePassesThroughAndRejects(t *testing.T) {
p := New()
remote := models.Remote{Name: "g", BaseURL: "https://github.com"}
if _, handled := serve(p, remote, "o/r/archive/refs/tags/v1.tar.gz"); handled {
t.Error("tag archives must go through the proxy engine")
}
if _, handled := serve(p, remote, "o/r/releases/download/v1/a.bin"); handled {
t.Error("release assets must go through the proxy engine")
}
if w, handled := serve(p, remote, "o/r/raw/main/x"); !handled || w.Code != http.StatusNotFound {
t.Errorf("unsupported path: handled=%v code=%d", handled, w.Code)
}
}
+3
View File
@@ -2,6 +2,7 @@ package proxy
import ( import (
"regexp" "regexp"
"strings"
"sync" "sync"
"git.unkin.net/unkin/artifactapi/internal/provider" "git.unkin.net/unkin/artifactapi/internal/provider"
@@ -37,7 +38,9 @@ func NewClassifier(p provider.Provider) *Classifier {
return &Classifier{provider: p} return &Classifier{provider: p}
} }
// Classify judges the bare path; a forwarded query must not change the verdict.
func (c *Classifier) Classify(remote models.Remote, path string) Classification { func (c *Classifier) Classify(remote models.Remote, path string) Classification {
path, _, _ = strings.Cut(path, "?")
if matchesAny(path, compilePatterns(remote.Blocklist)) { if matchesAny(path, compilePatterns(remote.Blocklist)) {
return ClassDenied return ClassDenied
} }
+18
View File
@@ -38,6 +38,24 @@ func TestClassifierBranches(t *testing.T) {
} }
} }
func TestClassifierIgnoresForwardedQuery(t *testing.T) {
gp, err := provider.Get(models.PackageGeneric)
if err != nil {
t.Fatal(err)
}
c := NewClassifier(gp)
if got := c.Classify(models.Remote{Blocklist: []string{`\.exe$`}}, "x.exe?limit=1"); got != ClassDenied {
t.Errorf("blocklist bypassed by query: %v", got)
}
allow := models.Remote{Patterns: []string{`/versions/$`}}
if got := c.Classify(allow, "a/versions/?limit=1&offset=100"); got != ClassImmutable {
t.Errorf("allowlisted paginated path = %v, want immutable", got)
}
if got := c.Classify(models.Remote{MutablePatterns: []string{`/versions/$`}}, "a/versions/?offset=100"); got != ClassMutable {
t.Errorf("mutable pattern on paginated path = %v, want mutable", got)
}
}
func TestClassificationString(t *testing.T) { func TestClassificationString(t *testing.T) {
for c, want := range map[Classification]string{ for c, want := range map[Classification]string{
ClassImmutable: "immutable", ClassImmutable: "immutable",
+91 -18
View File
@@ -134,10 +134,11 @@ func (e *Engine) Fetch(ctx context.Context, remote models.Remote, path string, p
slog.Warn("cache hit but S3 miss, re-fetching", "remote", remote.Name, "path", path) slog.Warn("cache hit but S3 miss, re-fetching", "remote", remote.Name, "path", path)
} }
locked, err := e.cache.AcquireLock(ctx, remote.Name, path, fetchLockTTL) token, err := e.cache.AcquireLock(ctx, remote.Name, path, fetchLockTTL)
if err != nil { if err != nil {
slog.Warn("lock acquire failed", "error", err) slog.Warn("lock acquire failed", "error", err)
} }
locked := token != ""
if !locked { if !locked {
// Another request holds the fetch lock. Poll the store until the leader // Another request holds the fetch lock. Poll the store until the leader
@@ -151,7 +152,7 @@ func (e *Engine) Fetch(ctx context.Context, remote models.Remote, path string, p
} }
if locked { if locked {
defer e.cache.ReleaseLock(ctx, remote.Name, path) defer func() { _ = e.cache.ReleaseLock(context.WithoutCancel(ctx), remote.Name, path, token) }()
} }
if class == ClassMutable && remote.CheckMutable { if class == ClassMutable && remote.CheckMutable {
@@ -216,7 +217,8 @@ func (e *Engine) Fetch(ctx context.Context, remote models.Remote, path string, p
// Evict drops path from every cache layer (artifact row, index object, Redis // Evict drops path from every cache layer (artifact row, index object, Redis
// freshness and ETag keys) so the next request refetches from upstream. A // freshness and ETag keys) so the next request refetches from upstream. A
// trailing "/*" evicts every path under that directory. // trailing "/*" evicts every path under that directory; a plain path also
// drops its forwarded-query variants ("path?...").
func (e *Engine) Evict(ctx context.Context, remoteName, path string) error { func (e *Engine) Evict(ctx context.Context, remoteName, path string) error {
prefix, wildcard := strings.CutSuffix(path, "*") prefix, wildcard := strings.CutSuffix(path, "*")
if wildcard && !strings.HasSuffix(prefix, "/") { if wildcard && !strings.HasSuffix(prefix, "/") {
@@ -239,11 +241,23 @@ func (e *Engine) Evict(ctx context.Context, remoteName, path string) error {
if err := e.store.Delete(ctx, storage.IndexKey(remoteName, path)); err != nil { if err := e.store.Delete(ctx, storage.IndexKey(remoteName, path)); err != nil {
return fmt.Errorf("delete index: %w", err) return fmt.Errorf("delete index: %w", err)
} }
return e.cache.ForgetPath(ctx, remoteName, path) if err := e.cache.ForgetPath(ctx, remoteName, path); err != nil {
return err
}
if strings.Contains(path, "?") {
return nil
}
return e.evictPrefix(ctx, remoteName, path+"?")
} }
// ponytail: no lock for wildcards; a Fetch already in flight under the // ponytail: no lock for wildcards; a Fetch already in flight under the
// prefix can re-cache its path after the evict. Per-path locks over a // prefix can re-cache its path after the evict. Per-path locks over a
// directory would close it if that ever matters. // directory would close it if that ever matters.
return e.evictPrefix(ctx, remoteName, prefix)
}
// evictPrefix drops every cached path starting with prefix, including the
// forwarded-query variants ("path?...") of a single path.
func (e *Engine) evictPrefix(ctx context.Context, remoteName, prefix string) error {
if err := e.db.DeleteArtifactsByPrefix(ctx, remoteName, prefix); err != nil { if err := e.db.DeleteArtifactsByPrefix(ctx, remoteName, prefix); err != nil {
return fmt.Errorf("delete artifacts: %w", err) return fmt.Errorf("delete artifacts: %w", err)
} }
@@ -257,31 +271,32 @@ func (e *Engine) Evict(ctx context.Context, remoteName, path string) error {
// release. It fails with a 503 when the lock cannot be taken within wait or // release. It fails with a 503 when the lock cannot be taken within wait or
// Redis errors. // Redis errors.
func (e *Engine) Lock(ctx context.Context, remoteName, path string, wait, ttl time.Duration) (func(), error) { func (e *Engine) Lock(ctx context.Context, remoteName, path string, wait, ttl time.Duration) (func(), error) {
if err := e.waitForLock(ctx, remoteName, path, wait, ttl); err != nil { token, err := e.waitForLock(ctx, remoteName, path, wait, ttl)
if err != nil {
return nil, err return nil, err
} }
return func() { _ = e.cache.ReleaseLock(context.WithoutCancel(ctx), remoteName, path) }, nil return func() { _ = e.cache.ReleaseLock(context.WithoutCancel(ctx), remoteName, path, token) }, nil
} }
func (e *Engine) waitForLock(ctx context.Context, remoteName, path string, wait, ttl time.Duration) error { func (e *Engine) waitForLock(ctx context.Context, remoteName, path string, wait, ttl time.Duration) (string, error) {
deadline := time.Now().Add(wait) deadline := time.Now().Add(wait)
for { for {
ok, err := e.cache.AcquireLock(ctx, remoteName, path, ttl) token, err := e.cache.AcquireLock(ctx, remoteName, path, ttl)
if ok { if token != "" {
return nil return token, nil
} }
if ctx.Err() != nil { if ctx.Err() != nil {
return ctx.Err() return "", ctx.Err()
} }
if err != nil { if err != nil {
return &ProxyError{Status: http.StatusServiceUnavailable, Message: fmt.Sprintf("fetch lock: %v", err)} return "", &ProxyError{Status: http.StatusServiceUnavailable, Message: fmt.Sprintf("lock: %v", err)}
} }
if time.Now().After(deadline) { if time.Now().After(deadline) {
return &ProxyError{Status: http.StatusServiceUnavailable, Message: "fetch in progress, retry evict"} return "", &ProxyError{Status: http.StatusServiceUnavailable, Message: "resource busy, retry later"}
} }
select { select {
case <-ctx.Done(): case <-ctx.Done():
return ctx.Err() return "", ctx.Err()
case <-time.After(50 * time.Millisecond): case <-time.After(50 * time.Millisecond):
} }
} }
@@ -310,12 +325,12 @@ func (e *Engine) Head(ctx context.Context, remote models.Remote, path string, pr
return &HeadResult{ContentType: info.ContentType, Size: info.Size, Source: "cache"}, nil return &HeadResult{ContentType: info.ContentType, Size: info.Size, Source: "cache"}, nil
} }
return e.headUpstream(ctx, remote, path, prov) return e.headUpstream(ctx, remote, path, prov, class)
} }
// headUpstream issues an upstream HEAD, load-balancing across the remote's base // headUpstream issues an upstream HEAD, load-balancing across the remote's base
// URLs and failing over to the next mirror on a network error or 5xx. // URLs and failing over to the next mirror on a network error or 5xx.
func (e *Engine) headUpstream(ctx context.Context, remote models.Remote, path string, prov provider.Provider) (*HeadResult, error) { func (e *Engine) headUpstream(ctx context.Context, remote models.Remote, path string, prov provider.Provider, class Classification) (*HeadResult, error) {
order := e.baseURLAttemptOrder(remote) order := e.baseURLAttemptOrder(remote)
if len(order) == 0 { if len(order) == 0 {
return nil, &ProxyError{Status: http.StatusBadGateway, Message: "no upstream base_url configured"} return nil, &ProxyError{Status: http.StatusBadGateway, Message: "no upstream base_url configured"}
@@ -323,7 +338,7 @@ func (e *Engine) headUpstream(ctx context.Context, remote models.Remote, path st
var lastErr error var lastErr error
for i, url := range order { for i, url := range order {
ctr := e.beginAttempt(remote, url) ctr := e.beginAttempt(remote, url)
result, err := e.headUpstreamOnce(ctx, withBaseURL(remote, url), path, prov) result, err := e.headUpstreamOnce(ctx, withBaseURL(remote, url), path, prov, class)
endAttempt(ctr) endAttempt(ctr)
if err == nil { if err == nil {
return result, nil return result, nil
@@ -338,7 +353,7 @@ func (e *Engine) headUpstream(ctx context.Context, remote models.Remote, path st
return nil, lastErr return nil, lastErr
} }
func (e *Engine) headUpstreamOnce(ctx context.Context, remote models.Remote, path string, prov provider.Provider) (*HeadResult, error) { func (e *Engine) headUpstreamOnce(ctx context.Context, remote models.Remote, path string, prov provider.Provider, class Classification) (*HeadResult, error) {
url := prov.UpstreamURL(remote, path) url := prov.UpstreamURL(remote, path)
authHeaders, err := prov.AuthHeaders(ctx, remote) authHeaders, err := prov.AuthHeaders(ctx, remote)
@@ -385,6 +400,11 @@ func (e *Engine) headUpstreamOnce(ctx context.Context, remote models.Remote, pat
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return nil, &ProxyError{Status: resp.StatusCode, Message: fmt.Sprintf("upstream returned %d", resp.StatusCode)} return nil, &ProxyError{Status: resp.StatusCode, Message: fmt.Sprintf("upstream returned %d", resp.StatusCode)}
} }
if class == ClassImmutable {
if err := e.checkQuarantine(ctx, remote, path, resp.Header); err != nil {
return nil, err
}
}
contentType := prov.ContentType(path) contentType := prov.ContentType(path)
if ct := resp.Header.Get("Content-Type"); ct != "" { if ct := resp.Header.Get("Content-Type"); ct != "" {
@@ -512,6 +532,11 @@ func (e *Engine) fetchFromUpstreamOnce(ctx context.Context, remote models.Remote
}, nil }, nil
} }
if err := e.checkQuarantine(ctx, remote, path, resp.Header); err != nil {
_ = resp.Body.Close()
return nil, err
}
// Immutable blobs are streamed through the content-addressable store // Immutable blobs are streamed through the content-addressable store
// (tempfile -> sha256 -> S3) so arbitrarily large artifacts never sit // (tempfile -> sha256 -> S3) so arbitrarily large artifacts never sit
// fully in memory. Immutable content is never rewritten in the proxy path. // fully in memory. Immutable content is never rewritten in the proxy path.
@@ -648,6 +673,43 @@ func (e *Engine) checkUpstreamOnce(ctx context.Context, remote models.Remote, pa
return resp.StatusCode == http.StatusNotModified, nil return resp.StatusCode == http.StatusNotModified, nil
} }
// checkQuarantine refuses an uncached immutable artifact published less than
// quarantine_days ago. The publish time is the upstream Last-Modified, capped
// at now, else the first time this path was seen.
func (e *Engine) checkQuarantine(ctx context.Context, remote models.Remote, path string, upstream http.Header) error {
if !remote.QuarantineEnabled {
return nil
}
if a, err := e.db.GetArtifact(ctx, remote.Name, path); err == nil && a != nil {
return nil
}
days := remote.QuarantineDays
if days < 1 {
days = models.DefaultQuarantineDays
}
window := time.Duration(days) * 24 * time.Hour
now := time.Now()
published, err := http.ParseTime(upstream.Get("Last-Modified"))
if err != nil {
if published, err = e.cache.FirstSeen(ctx, remote.Name, path, window+24*time.Hour); err != nil {
slog.Warn("first-seen lookup failed", "remote", remote.Name, "path", path, "error", err)
return &ProxyError{Status: http.StatusServiceUnavailable, Message: "quarantine check unavailable", RetryAfter: 30}
}
}
if published.After(now) {
published = now
}
if until := published.Add(window); now.Before(until) {
return &ProxyError{
Status: http.StatusForbidden,
Message: fmt.Sprintf("quarantined: published %s, available from %s", published.UTC().Format(time.RFC3339), until.UTC().Format(time.RFC3339)),
Published: published.UTC(),
Available: until.UTC(),
}
}
return nil
}
// etagTTL keeps a mutable path's ETag well past its freshness key so an expired // etagTTL keeps a mutable path's ETag well past its freshness key so an expired
// path revalidates with a conditional HEAD, while idle paths still age out. // path revalidates with a conditional HEAD, while idle paths still age out.
func etagTTL(ttl time.Duration) time.Duration { func etagTTL(ttl time.Duration) time.Duration {
@@ -806,10 +868,21 @@ func fetchBearerToken(ctx context.Context, wwwAuth string, remote models.Remote)
type ProxyError struct { type ProxyError struct {
Status int Status int
Message string Message string
// Published and Available are set only on a quarantine refusal.
Published time.Time
Available time.Time
// RetryAfter, in seconds, is sent as Retry-After when set.
RetryAfter int
} }
func (e *ProxyError) Error() string { return e.Message } func (e *ProxyError) Error() string { return e.Message }
// Quarantined reports whether err is a quarantine refusal.
func Quarantined(err error) bool {
var pe *ProxyError
return errors.As(err, &pe) && !pe.Published.IsZero()
}
type UpstreamError struct { type UpstreamError struct {
Err error Err error
} }
+1 -1
View File
@@ -227,7 +227,7 @@ func TestHeadUpstreamUsesRemoteTimeout(t *testing.T) {
r := models.Remote{Name: "slow-head", PackageType: models.PackageGeneric, BaseURL: slow.URL, UpstreamResponseHeaderTimeout: 1} r := models.Remote{Name: "slow-head", PackageType: models.PackageGeneric, BaseURL: slow.URL, UpstreamResponseHeaderTimeout: 1}
start := time.Now() start := time.Now()
_, err := (&Engine{}).headUpstreamOnce(context.Background(), r, "blob.bin", prov(t, models.PackageGeneric)) _, err := (&Engine{}).headUpstreamOnce(context.Background(), r, "blob.bin", prov(t, models.PackageGeneric), ClassImmutable)
if err == nil || time.Since(start) > 2*time.Second { if err == nil || time.Since(start) > 2*time.Second {
t.Fatalf("HEAD ignored the remote's response header timeout: err=%v after %s", err, time.Since(start)) t.Fatalf("HEAD ignored the remote's response header timeout: err=%v after %s", err, time.Since(start))
} }
+36 -9
View File
@@ -152,13 +152,15 @@ func TestEvictUnknownRemote(t *testing.T) {
} }
} }
func holdLock(t *testing.T, remote, path string) { func holdLock(t *testing.T, remote, path string) string {
t.Helper() t.Helper()
ctx := context.Background() ctx := context.Background()
if ok, err := testCache.AcquireLock(ctx, remote, path, time.Minute); !ok || err != nil { token, err := testCache.AcquireLock(ctx, remote, path, time.Minute)
t.Fatalf("acquire: %v %v", ok, err) if token == "" || err != nil {
t.Fatalf("acquire: %q %v", token, err)
} }
t.Cleanup(func() { _ = testCache.ReleaseLock(ctx, remote, path) }) t.Cleanup(func() { _ = testCache.ReleaseLock(ctx, remote, path, token) })
return token
} }
func TestEvictWaitsForFetchLock(t *testing.T) { func TestEvictWaitsForFetchLock(t *testing.T) {
@@ -167,7 +169,7 @@ func TestEvictWaitsForFetchLock(t *testing.T) {
r := rpmRemote(t, "evict-lock", srv.URL) r := rpmRemote(t, "evict-lock", srv.URL)
ctx := context.Background() ctx := context.Background()
const path = "8/Everything/x86_64/repodata/repomd.xml" const path = "8/Everything/x86_64/repodata/repomd.xml"
holdLock(t, r.Name, path) token := holdLock(t, r.Name, path)
done := make(chan error, 1) done := make(chan error, 1)
go func() { done <- testEngine.Evict(ctx, r.Name, path) }() go func() { done <- testEngine.Evict(ctx, r.Name, path) }()
select { select {
@@ -175,13 +177,13 @@ func TestEvictWaitsForFetchLock(t *testing.T) {
t.Fatalf("evict returned while fetch lock held: %v", err) t.Fatalf("evict returned while fetch lock held: %v", err)
case <-time.After(200 * time.Millisecond): case <-time.After(200 * time.Millisecond):
} }
_ = testCache.ReleaseLock(ctx, r.Name, path) _ = testCache.ReleaseLock(ctx, r.Name, path, token)
if err := <-done; err != nil { if err := <-done; err != nil {
t.Fatalf("evict: %v", err) t.Fatalf("evict: %v", err)
} }
ok, err := testCache.AcquireLock(ctx, r.Name, path, time.Second) token, err := testCache.AcquireLock(ctx, r.Name, path, time.Second)
if !ok || err != nil { if token == "" || err != nil {
t.Fatalf("lock still held after evict: %v %v", ok, err) t.Fatalf("lock still held after evict: %q %v", token, err)
} }
} }
@@ -269,3 +271,28 @@ func TestEvictWildcardClearsPrefixOnly(t *testing.T) {
t.Errorf("path outside prefix = %q, want cached rev1", got) t.Errorf("path outside prefix = %q, want cached rev1", got)
} }
} }
func TestEvictPathDropsQueryVariants(t *testing.T) {
requireStack(t)
srv, _, _ := changingUpstream(t)
r := rpmRemote(t, "evict-query", srv.URL)
const (
path = "8/Everything/x86_64/repodata/repomd.xml"
variant = path + "?limit=10"
sibling = "8/Everything/x86_64/repodata/repomd.xml.asc"
)
for _, p := range []string{path, variant, sibling} {
fetchBody(t, r, p)
}
if err := testEngine.Evict(context.Background(), r.Name, path); err != nil {
t.Fatalf("evict: %v", err)
}
for _, p := range []string{path, variant} {
if c := layers(t, r.Name, p); c != (cached{}) {
t.Errorf("%s after evict = %+v, want every layer gone", p, c)
}
}
if c := layers(t, r.Name, sibling); c == (cached{}) {
t.Errorf("%s sharing the name prefix was evicted", sibling)
}
}

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