Compare commits

..

26 Commits

Author SHA1 Message Date
unkin-agent e59d9728b2 Document the repodata fingerprint invariant (#178)
ci/woodpecker/tag/docker Pipeline was successful
The cache_repodata fingerprint (count, max id) only detects every change while one writer at a time touches a remote's rows; with interleaved writers a late-committing lower id plus a delete can leave it unchanged. In the deployed server, github_rpm scans run under the per-remote sync lease, which holds only while the lease outlives the scan timeout.

- note the invariant on RPMMetadataFingerprint, scoped to github_rpm remotes
- state it in the Repodata cache section of the github_rpm docs
- add a test asserting syncLeaseDuration > defaultScanTimeout

Reviewed-on: #178
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-10-25 22:53:04 +11:00
unkin-agent e4833b6f96 Make repodata coalescing tests deterministic (#177)
The repodata coalescing tests released the gated build after a fixed 50ms sleep, so a goroutine stalled past that window under -race could miss the in-flight singleflight call, trigger a second build and flake the test.

- add sfWaiters, counting goroutines parked in singleflight Do via runtime.Stack
- release the gate only once every follower is parked on the in-flight call
- drop the trailing sleep from waitFor

Reviewed-on: #177
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-10-25 22:51:03 +11:00
unkin-agent 7f65fc377c Document sync_interval for deb/alpine and fix created_at gate on existing rows (#176)
`sync_interval` is documented only for github_rpm, and the claim gate holding back remotes created inside the current slot also blocks remotes that already have a sync state row: the gate filters the INSERT SELECT, so ON CONFLICT never runs and a failed cold-cache prime waits a full slot instead of its backoff.

- document `sync_interval` for github_deb/github_alpine and in the caching field table
- apply the created_at gate only when no sync state row exists
- test 0→N and N→0 interval changes and the insert-only gate against Postgres
- test that a negative `sync_interval` on update returns 400

Reviewed-on: #176
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-10-25 20:26:34 +11:00
unkin-agent 00edeb478b Show virtual parallel_fetch in UI and TUI (#173)
Virtuals carry a `parallel_fetch` flag in the v2 API, but the UI type omits it and neither the UI nor the TUI displays it, so the setting is invisible to operators.

- add `parallel_fetch` to the TS `Virtual` type
- add a Parallel column with a yellow badge to the UI virtuals table
- use one `selected` lookup for the virtual detail panel
- add a parallel column to the TUI virtuals list, padding the member count (`%3d members`) so columns align

Reviewed-on: #173
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-10-25 20:25:00 +11:00
unkin-agent f5d30cc96e Keep stale virtual member copy when the remote lookup fails (#171)
A transient remote lookup error dropped the serve_stale copy of a virtual member and forced a blocking fetch, so a short DB blip stalled every merge.

- serve the existing copy on a lookup error and refresh it in the background
- fetch directly without storing anything on a lookup error with no copy
- create copies only after a successful lookup with serve_stale on

Reviewed-on: #171
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-10-25 20:24:20 +11:00
unkin-agent a4a4fe57c9 Require both members in RPM member-order test (#175)
Both members in the parallel_fetch member-order test publish the same NEVRA. The merge dedupes them, so b.rpm never reaches the merged primary, and the assertion skips the order check whenever b.rpm is missing.

- give each member a distinct package name so both packages appear in the merged primary
- require a.rpm and b.rpm both present, with a before b

Reviewed-on: #175
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-10-25 20:23:39 +11:00
unkin-agent 5f33abba93 Cap sync_interval at 30 days (#174)
sync_interval has no upper bound, so a value above MaxInt32 passes validation and then fails the INTEGER column insert as a 500, and very large values overflow time.Duration.

- add MaxSyncInterval (30 days in seconds)
- reject sync_interval above it with a 400 via ValidateSyncInterval
- cover the boundary in TestValidateSyncInterval and over-limit values in TestSyncIntervalAPI

Reviewed-on: #174
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-10-25 20:23:01 +11:00
unkin-agent a75aef8ded Make serve_stale refresh test deterministic (#172)
TestServeStaleMemberDoesNotBlockMerge reads the fetch counter right after the merges return, but the counted fetch runs inside the background refresh goroutine, so a slow schedule sees 1 instead of 2.

- signal from the stub fetch when the background refresh starts
- wait on that signal (5s timeout) before checking the fetch count
- raise the refresh-reaches-merge deadline to 5s

Reviewed-on: #172
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-10-25 20:22:09 +11:00
unkin-agent f1b78988e4 Add cache_repodata toggle for github_rpm remotes (#168)
github_rpm remotes regenerate repomd and all three gz files from every metadata row on each request, which is expensive for large repos under repeated dnf traffic.

- add a per-remote `cache_repodata` toggle (column, model, v2 API, docs)
- cache built repodata per replica, rebuilt via singleflight when the `count(*)`+`max(id)` row fingerprint moves
- store a build only if no newer build has landed; evict idle entries hourly
- map cached-path DB errors like the uncached path: 503 + Retry-After on cancel/deadline, generic 500 otherwise
- check `cacheEmpty` via the fingerprint for all github_rpm remotes, not only cached ones

Reviewed-on: #168
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-10-25 20:21:19 +11:00
unkin-agent ee55ef3fcd Add per-remote sync_interval and spread GitHub syncs (#166)
ci/woodpecker/tag/docker Pipeline was successful
github_* remotes sharing a mutable_ttl all become due at the same moment, so many remotes created or synced together burst GitHub at once.

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

Reviewed-on: #166
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-10-25 18:58:22 +11:00
unkin-agent 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
131 changed files with 9602 additions and 272 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.
Classification precedence, TTLs and eviction for every remote: [docs/common/caching.md](docs/common/caching.md).
Write authentication and per-client token usage: [docs/common/auth.md](docs/common/auth.md).
Each type's paths, Terraform examples and local/virtual behaviour are in `docs/<type>/`:
- `alpine`: [remote](docs/alpine/remote.md), [remote-github](docs/alpine/remote-github.md)
- `alpine`: [remote](docs/alpine/remote.md), [remote-github](docs/alpine/remote-github.md), [virtual](docs/alpine/virtual.md)
- `ansible`: [remote](docs/ansible/remote.md), [local](docs/ansible/local.md), [virtual](docs/ansible/virtual.md)
- `cargo`: [remote](docs/cargo/remote.md)
- `deb`: [remote](docs/deb/remote.md), [remote-github](docs/deb/remote-github.md)
- `deb`: [remote](docs/deb/remote.md), [remote-github](docs/deb/remote-github.md), [virtual](docs/deb/virtual.md)
- `docker`: [remote](docs/docker/remote.md), [local](docs/docker/local.md)
- `generic`: [remote](docs/generic/remote.md)
- `gems`: [remote](docs/gems/remote.md), [local](docs/gems/local.md), [virtual](docs/gems/virtual.md)
- `goproxy`: [remote](docs/goproxy/remote.md)
- `goproxy`: [remote](docs/goproxy/remote.md), [local](docs/goproxy/local.md), [virtual](docs/goproxy/virtual.md)
- `helm`: [remote](docs/helm/remote.md), [local](docs/helm/local.md), [virtual](docs/helm/virtual.md)
- `npm`: [remote](docs/npm/remote.md), [local](docs/npm/local.md), [virtual](docs/npm/virtual.md)
- `nuget`: [remote](docs/nuget/remote.md)
- `puppet`: [remote](docs/puppet/remote.md), [local](docs/puppet/local.md)
- `puppet`: [remote](docs/puppet/remote.md), [local](docs/puppet/local.md), [virtual](docs/puppet/virtual.md)
- `pypi`: [remote](docs/pypi/remote.md)
- `rpm`: [remote](docs/rpm/remote.md), [remote-github](docs/rpm/remote-github.md)
- `terraform`: [remote](docs/terraform/remote.md), [local](docs/terraform/local.md)
- `vcs`: [remote](docs/vcs/remote.md)
### GitHub authentication
@@ -200,14 +203,15 @@ additive and idempotent for the same reason; a test enforces it.
| `GITHUB_SYNC_RATE` | `1` | `github_rpm` syncer global GitHub request rate (req/s), shared across all remotes. `1`/s = 3600/hr, under an authenticated token's ~5000/hr; unauthenticated (~60/hr) relies on ETag/304 |
| `GITHUB_SYNC_BURST` | `5` | Token-bucket burst for the shared limiter |
| `GITHUB_SYNC_WORKERS` | `3` | Concurrent `github_rpm` scan workers |
| `GITHUB_SYNC_POLL_INTERVAL` | `60` | Base scheduler tick in seconds; per-remote cadence is its `mutable_ttl`, enforced by the DB lease |
| `GITHUB_SYNC_POLL_INTERVAL` | `60` | Base scheduler tick in seconds; per-remote cadence is its `sync_interval` (phase-spread), else `mutable_ttl`, enforced by the DB lease |
| `GITHUB_TOKEN` | | Server-level GitHub PAT (fine-grained or classic), sent as `Authorization: Bearer`. Applies to every GitHub request; per-remote creds override it. See [GitHub authentication](#github-authentication) |
| `GITHUB_APP_ID` | | GitHub App id (App auth mode; mutually exclusive with `GITHUB_TOKEN`) |
| `GITHUB_APP_INSTALLATION_ID` | | GitHub App installation id |
| `GITHUB_APP_PRIVATE_KEY` | | GitHub App private key, inline PEM |
| `GITHUB_APP_PRIVATE_KEY_PATH` | | GitHub App private key, file path (alternative to inline PEM) |
| `ARTIFACTAPI_OIDC_ISSUER` | | OIDC issuer URL (e.g. Vault `https://vault.service.consul:8200/v1/identity/oidc`). When set, `POST /api/v2/remotes`, `POST /api/v2/virtuals` and `PUT`/`DELETE` on `/api/v2/{remotes,virtuals}/{name}` require `Authorization: Bearer <jwt>` verified against the issuer's JWKS. Uploads, object/file deletes, probe, reads and `/v2` stay open. Unset = no auth |
| `ARTIFACTAPI_OIDC_ISSUER` | | OIDC issuer URL (e.g. Vault `https://vault.example.com/v1/identity/oidc`). When set, `POST /api/v2/remotes`, `POST /api/v2/virtuals` and `PUT`/`DELETE` on `/api/v2/{remotes,virtuals}/{name}` require a token verified against the issuer's JWKS. Unset = no auth. See [docs/common/auth.md](docs/common/auth.md) |
| `ARTIFACTAPI_OIDC_AUDIENCE` | | Comma-separated allowed `aud` values (e.g. the Vault OIDC role's client_id). Required when `ARTIFACTAPI_OIDC_ISSUER` is set |
| `ARTIFACTAPI_AUTH_UPLOADS` | `false` | Also require a token for every local repo write: file uploads/deletes, object evictions, `/api/v1/local` publishes and `/v2` push. Requires `ARTIFACTAPI_OIDC_ISSUER` |
## Development
+13
View File
@@ -5,3 +5,16 @@ Shared classification, TTL and eviction rules: [caching](../common/caching.md).
| Type | Mutable (auto-detected) | Immutable (auto-detected) |
|---|---|---|
| `github_alpine` | `APKINDEX.tar.gz` (synthesized) | `.apk` (redirected) |
## Release sync
A background syncer re-scans the GitHub releases list per remote, sharing one
lease per remote across replicas through `github_alpine_sync_state`.
- **`sync_interval`** (seconds) re-checks once per interval at a stable
per-remote offset, so remotes on the same interval spread out instead of
syncing together; its first scheduled sync waits for that slot (a cold-cache
request still primes at once). Unset, it re-checks no more often than its
`mutable_ttl`.
- A failed scan keeps serving the last good index and retries with exponential
backoff.
+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.
+23 -3
View File
@@ -13,9 +13,11 @@ Every proxied request on a remote is classified as **denied**, **immutable** or
| `mutable_patterns` | Regexes forcing a path mutable. |
| `immutable_ttl` | Seconds an immutable path stays fresh. `0` = forever. |
| `mutable_ttl` | Seconds a mutable path stays fresh. `0` also means never expires. Omitted on create, it defaults to `3600`; omitted on update, the stored value is kept. |
| `sync_interval` | github_rpm/github_deb/github_alpine only. Seconds between background release re-scans, at a stable per-remote offset. Unset (`0`) re-scans no more often than `mutable_ttl`. |
| `check_mutable` | When a mutable path's `mutable_ttl` expires, sends a conditional `HEAD` (`If-None-Match`) with the stored upstream ETag. A `304` re-arms the TTL and serves the stored copy; anything else refetches in full. The ETag is kept for `max(10 × mutable_ttl, 7 days)`, re-armed on each `304`, so idle paths age out. It is only stored when `check_mutable` is set and `mutable_ttl` is non-zero, and never for immutable paths. |
| `stale_on_error` | On an upstream network error, serve the stored copy instead of failing. |
| `quarantine_enabled` / `quarantine_days` | Stored on the remote but not enforced by the proxy yet. |
| `serve_stale` | rpm/deb/alpine virtual members only. When a virtual re-merges, use this member's previous index at once and refresh it in the background; the next merge picks up the refresh. The first merge still waits, and so does any merge once the previous index is over an hour old, so a member that keeps failing surfaces its error. Default `false`. |
| `quarantine_enabled` / `quarantine_days` | Refuses immutable artifacts published less than `quarantine_days` (default `3`) ago. See [Quarantine](#quarantine). |
| `mirrorlist` / `mirror_strategy` | rpm/deb/alpine only. Upstream pool is `[base_url] + mirrorlist`; `round_robin` (default) rotates the starting mirror, `least_conn` starts at the one with the fewest in-flight requests. Network errors and 5xx fail over to the next mirror; other statuses return as-is. |
## Precedence
@@ -52,6 +54,24 @@ So `blocklist` beats the allowlist, and `immutable_patterns` beats
Concurrent misses on one path share a fetch lock. Waiters poll the store for
up to 5s instead of all going upstream.
## Quarantine
With `quarantine_enabled`, an uncached **immutable** path whose upstream copy
was published less than `quarantine_days` ago answers `403` and is not cached:
```json
{"error": "quarantined: published 2026-10-24T09:00:00Z, available from 2026-10-27T09:00:00Z",
"published": "2026-10-24T09:00:00Z", "available": "2026-10-27T09:00:00Z"}
```
- `quarantine_days` must be at least `1` when quarantine is enabled.
- The publish time is the upstream `Last-Modified` header (capped at now), else the first time artifactapi was asked for the path.
- If that first-seen lookup fails, the path answers `503` with `Retry-After` instead.
- Mutable paths (indexes, metadata) are never quarantined.
- Paths already cached keep serving, including ones cached before quarantine was enabled.
- A virtual redirects to the member that owns the path, so its `403` reaches the client. A goproxy virtual probing for an unlisted version skips a quarantining member and only redirects to it when no other member serves the version.
- Indexes still list fresh versions, so a client resolving "latest" may pick one and get the `403`.
## Eviction
```sh
@@ -93,9 +113,9 @@ Force a moving path mutable, e.g. branch tarballs on `generic`:
## Built-in classification per type
Each type's page lists its auto-detected mutable and immutable paths:
[alpine](../alpine/remote.md), [alpine github](../alpine/remote-github.md),
[alpine](../alpine/remote.md), [alpine github](../alpine/remote-github.md), [ansible](../ansible/remote.md),
[cargo](../cargo/remote.md), [deb](../deb/remote.md), [deb github](../deb/remote-github.md),
[docker](../docker/remote.md), [generic](../generic/remote.md), [gems](../gems/remote.md), [goproxy](../goproxy/remote.md),
[helm](../helm/remote.md), [npm](../npm/remote.md), [nuget](../nuget/remote.md),
[puppet](../puppet/remote.md), [pypi](../pypi/remote.md), [rpm](../rpm/remote.md),
[rpm github](../rpm/remote-github.md), [terraform](../terraform/remote.md).
[rpm github](../rpm/remote-github.md), [terraform](../terraform/remote.md), [vcs](../vcs/remote.md).
+13
View File
@@ -5,3 +5,16 @@ Shared classification, TTL and eviction rules: [caching](../common/caching.md).
| Type | Mutable (auto-detected) | Immutable (auto-detected) |
|---|---|---|
| `github_deb` | `Packages*`, `Release*` (synthesized) | `.deb` (redirected) |
## Release sync
A background syncer re-scans the GitHub releases list per remote, sharing one
lease per remote across replicas through `github_deb_sync_state`.
- **`sync_interval`** (seconds) re-checks once per interval at a stable
per-remote offset, so remotes on the same interval spread out instead of
syncing together; its first scheduled sync waits for that slot (a cold-cache
request still primes at once). Unset, it re-checks no more often than its
`mutable_ttl`.
- A failed scan keeps serving the last good index and retries with exponential
backoff.
+32
View File
@@ -0,0 +1,32 @@
# `deb` virtual
A deb virtual serves one apt source over local and remote deb members at
`/api/v1/virtual/<name>/`, in either the flat (`./`) or `dists/<suite>` layout.
- Packages indexes are merged; on a duplicate (Package, Version, Architecture)
the earlier member wins.
- The Release is generated unsigned with `Acquire-By-Hash: yes`; indexes are
served by name and by SHA256 from the current and previous merge.
- `Filename` points at the virtual (`<member>/<path>`), which 302s to the
owning member.
- All members must share one layout; mixing flat and suite members is
unsupported (suite requests fail with 502).
- A member error fails the request with 502. Merges are cached for 60s per
suite, up to 32 suites per virtual.
```hcl
resource "artifactapi_virtual" "deb" {
name = "deb"
package_type = "deb"
members = [artifactapi_local_deb.internal.name, artifactapi_remote_github_deb.tool.name]
}
```
```
# /etc/apt/sources.list.d/artifactapi.list (flat)
deb [trusted=yes] https://artifactapi.example.com/api/v1/virtual/deb/ ./
# suite layout
deb [trusted=yes] https://artifactapi.example.com/api/v1/virtual/debian/ trixie main
```
Upload to the local member directly; the virtual is read-only.
+3
View File
@@ -10,6 +10,9 @@ docker push artifactapi.example.com/docker-internal/myapp:latest
docker pull artifactapi.example.com/docker-internal/myapp:latest
```
With [uploads gated](../common/auth.md), `docker login` with any username and
a token as the password before pushing; pulls stay anonymous.
The first path segment after `/v2/` is the artifactapi repo name; the remainder
is the image name. Blobs and manifests are stored through the shared
content-addressable store (deduplicated by digest, reaped by GC once
+1 -1
View File
@@ -2,7 +2,7 @@
A local `gems` repo is a RubyGems source: `gem push`, `gem yank`, `gem install`
and `bundle install` work against `/api/v1/local/<repo>`. `gem push` needs an
API key; any value works.
API key; any value works unless [uploads are gated](../common/auth.md).
```bash
GEM_HOST_API_KEY=unused gem push --host https://artifactapi.example.com/api/v1/local/<repo> mygem-1.0.0.gem
+20
View File
@@ -0,0 +1,20 @@
# Local Go module proxy
A local `goproxy` repo serves private modules over the GOPROXY protocol
(`@v/list`, `@latest`, `@v/<ver>.info|.mod|.zip`). Publish a version by
uploading its files under the case-encoded module path (uppercase as `!lower`):
```sh
for ext in zip mod info; do
curl -fsSL --upload-file v0.1.0.$ext \
https://artifactapi.k8s.syd1.au.unkin.net/api/v2/remotes/go-internal/files/example.unkin.net/hello/@v/v0.1.0.$ext
done
GOPROXY=https://artifactapi.k8s.syd1.au.unkin.net/api/v1/local/go-internal,https://proxy.golang.org,direct \
GONOSUMDB=example.unkin.net go get example.unkin.net/hello@latest
```
Build the zip with `golang.org/x/mod/zip` or reuse the files `go mod download`
leaves in `$(go env GOMODCACHE)/cache/download`. Uploads are validated: zips must
pass `zip.CheckZip` (every file under `module@version/`), `.mod` must declare the
module, and `.info` must match the version. `.info` is optional; it is
synthesized from the version when absent. `@v/list` lists versions with a `.mod`.
+30
View File
@@ -0,0 +1,30 @@
# `goproxy` virtual
A goproxy virtual serves one GOPROXY over local and remote goproxy members:
`GOPROXY=https://artifactapi.example.com/api/v1/virtual/<name>`.
- `<module>/@v/list` is the union of member versions, sorted by semver.
- `<module>/@latest` is the highest release in that union, else the highest
prerelease; with no listed versions it falls back to the first member that
answers `@latest`.
- `@latest` and `@v/<version>.{info,mod,zip}` 302 to the first member, in
member order, that lists the version (or, for unlisted pseudo-versions,
serves its `.info`). Redirects are root-relative and `no-store`.
- Module paths are case-encoded (uppercase as `!lower`).
- A member error fails the request with 502; a member answering 404 or 410 is
skipped. Merged lists are cached for 60s.
```hcl
resource "artifactapi_virtual" "go" {
name = "go"
package_type = "goproxy"
members = [artifactapi_local_goproxy.internal.name, artifactapi_remote_goproxy.goproxy.name]
}
```
```sh
export GOPROXY=https://artifactapi.example.com/api/v1/virtual/go GONOSUMDB=example.unkin.net
go get example.unkin.net/hello@latest golang.org/x/text@latest
```
Publish to the local member directly; the virtual is read-only.
+2 -1
View File
@@ -2,7 +2,8 @@
A local `npm` repo is a real npm registry: `npm publish` (scoped and unscoped)
and `npm install` work against `/api/v1/local/<repo>/`. npm requires an
`_authToken` for the registry before it will publish; any value works.
`_authToken` for the registry before it will publish; any value works unless
[uploads are gated](../common/auth.md).
```ini
# .npmrc
+3
View File
@@ -5,3 +5,6 @@ Shared classification, TTL and eviction rules: [caching](../common/caching.md).
| Type | Mutable (auto-detected) | Immutable (auto-detected) |
|---|---|---|
| `puppet` | `v3/modules/*`, `v3/releases*` | `.tar.gz` |
`/v3/releases` forwards the `module`, `limit` and `offset` query params
upstream; set `base_url` to the Forge API root, e.g. `https://forgeapi.puppet.com`.
+37
View File
@@ -0,0 +1,37 @@
# `puppet` virtual
A puppet virtual serves one Forge v3 API over local and remote puppet members:
`puppet module install` and r10k work against `/api/v1/virtual/<name>`, with
dependencies resolved across members.
- `/v3/releases?module=<slug>` merges member releases; on a duplicate version
the earlier member wins. Releases are newest first, paginated with
`limit` (up to 100) and `offset`; pagination links and `file_uri` are
relative to the Forge root, so downloads route via the virtual.
- `/v3/modules/<slug>` lists the merged releases with the highest version as
`current_release`.
- `/v3/releases/<slug>-<version>` and `/v3/files/<file>` 302 to the member
owning that release.
- A member error fails the request with 502; a member without the module is
skipped. Merges, and modules no member has, are cached for 60s.
```hcl
resource "artifactapi_virtual" "puppet" {
name = "puppet"
package_type = "puppet"
members = [artifactapi_local_puppet.internal.name, artifactapi_remote_puppet.forge.name]
}
```
```bash
puppet module install unkin-hello \
--module_repository https://artifactapi.example.com/api/v1/virtual/puppet
```
```yaml
# r10k.yaml
forge:
baseurl: https://artifactapi.example.com/api/v1/virtual/puppet
```
Upload to the local member directly; the virtual is read-only.
+21 -6
View File
@@ -32,10 +32,14 @@ derived metadata current off the client request path:
The first `dnf` request is served from cache. If a request arrives before the
prime lands, it returns a retryable `503` (with `Retry-After`) rather than
serving an empty repo or blocking on a multi-minute derive.
- **Periodic re-check, driven by `mutable_ttl`.** Each remote is re-checked for
new or changed releases no more often than its `mutable_ttl`. New/changed
assets are derived incrementally; assets already cached are never re-fetched,
and assets that disappear upstream are pruned.
- **Periodic re-check, driven by `sync_interval` or `mutable_ttl`.** With
`sync_interval` (seconds) set, a remote re-checks once per interval at a
stable per-remote offset, so remotes on the same interval spread out instead
of syncing together; its first scheduled sync waits for that slot (a
cold-cache request still primes at once). Unset, it re-checks no more often
than its `mutable_ttl`. New/changed assets are derived incrementally; assets
already cached are never re-fetched, and assets that disappear upstream are
pruned.
- **ETag / 304 conditional requests.** The releases-list `ETag` is stored per
remote and sent as `If-None-Match`; a `304 Not Modified` means nothing changed
and the syncer derives nothing. GitHub does not count `304` conditional
@@ -50,8 +54,8 @@ derived metadata current off the client request path:
periodic scan a replica must atomically claim a per-remote lease
(`github_rpm_sync_state`: `last_synced_at`, `etag`, `sync_lease_owner`,
`sync_lease_expires`); only the winner scans. This bounds total GitHub load to
~once per `mutable_ttl` regardless of replica count, and the shared `etag`
lets any replica issue the conditional request.
~once per `sync_interval` (else `mutable_ttl`) regardless of replica count,
and the shared `etag` lets any replica issue the conditional request.
```hcl
# Backend that serves the actual .rpm bytes from github.com.
@@ -68,6 +72,7 @@ resource "artifactapi_remote_github_rpm" "acme-tools" {
base_url = "https://api.github.com/repos/acme/tools" # the releases API root
releases_remote = "github" # backend for downloads
mutable_ttl = 3600 # release re-scan interval
cache_repodata = true # keep generated repodata in memory
# Optional: restrict which release assets become packages (regex on filename).
patterns = [".*\\.x86_64\\.rpm$", ".*\\.noarch\\.rpm$"]
@@ -80,3 +85,13 @@ resource "artifactapi_remote_github_rpm" "acme-tools" {
`dnf` config: `baseurl=https://artifactapi.example/api/v1/remote/acme-tools`.
The repo is multi-arch (no `$basearch` needed) — `dnf` selects matching packages
from the synthesized metadata.
### Repodata cache
Repodata is regenerated from the metadata rows on every request by default. Set
`cache_repodata = true` on the remote to keep the generated `repomd.xml` and its
`primary`/`filelists`/`other` files in memory per replica. Each request checks a
cheap row fingerprint (count + newest id) in the database and rebuilds only when
it moved, so every replica picks up a scan's changes on its next request. The
fingerprint relies on only one scan writing a remote's rows at a time; in the
deployed server, the sync lease serialises scans across replicas.
+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()`,
second version, republish rejected), gems (stock `ruby:3.3`: `gem push`,
`gem install`, `bundle install` + `require`, republish rejected, `gem yank`,
yanked re-push rejected).
yanked re-push rejected), ansible (stock `ansible-galaxy`: `collection build`,
`publish`, republish rejected, `install` + module run).
- **Virtual repositories** — pypi simple-index merge and helm `index.yaml` merge
across two members; npm packument merge over two locals via a stock `node:22`
client (merged versions, install through the member redirect); gems compact
index merge over two locals via stock `ruby:3.3` bundler (member order wins,
downloads 302 to the owning member).
downloads 302 to the owning member); ansible over two locals via stock
`ansible-galaxy` (install through the virtual, download 302 to the owner);
goproxy over two locals via a stock `golang:1.25` client (merged versions,
member order wins, version files 302 to the owning member); puppet over two
locals via real `puppet module install` (dependency resolved across members)
and `r10k`, downloads 302 to the owning member.
- **Mirrorlist** — an rpm remote with a `mirrorlist` of extra upstream mirrors
(pool = `base_url` + `mirrorlist`): round-robin distribution across both mirrors
(constant-body `mockupstreama` / `mockupstreamb`), failover past a dead primary,
+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")
uploadFile(t, "local-alpine", "x86_64/e2e-testpkg-1.0-r0.apk", apk, "application/vnd.android.package-archive")
// The index is generated asynchronously after upload; poll for it.
resp, body := getEventually(t, api("/api/v1/local/local-alpine/x86_64/APKINDEX.tar.gz"), 15*time.Second)
if resp.StatusCode != http.StatusOK {
t.Fatalf("APKINDEX: status %d: %s", resp.StatusCode, body)
}
zr, err := gzip.NewReader(bytes.NewReader(body))
if err != nil {
t.Fatalf("APKINDEX not gzip: %v", err)
}
tarBytes, _ := io.ReadAll(zr)
tr := tar.NewReader(bytes.NewReader(tarBytes))
// The index is served (empty) at once but lists the package only once the
// upload's metadata is parsed asynchronously; poll until it does.
var index string
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("APKINDEX not tar: %v", err)
}
if hdr.Name == "APKINDEX" {
b, _ := io.ReadAll(tr)
index = string(b)
for deadline := time.Now().Add(15 * time.Second); !strings.Contains(index, "P:e2e-testpkg") && time.Now().Before(deadline); time.Sleep(250 * time.Millisecond) {
resp, body := doRequest(t, http.MethodGet, api("/api/v1/local/local-alpine/x86_64/APKINDEX.tar.gz"), nil, "")
if resp.StatusCode != http.StatusOK {
t.Fatalf("APKINDEX: status %d: %s", resp.StatusCode, body)
}
index = apkIndexText(t, body)
}
for _, want := range []string{"P:e2e-testpkg", "V:1.0-r0", "A:x86_64", "C:Q1", "S:", "I:"} {
if !strings.Contains(index, want) {
@@ -183,7 +167,7 @@ func TestLocalAlpineIndex(t *testing.T) {
}
// The .apk downloads back byte-identical from its arch path.
resp, body = doRequest(t, http.MethodGet, api("/api/v1/local/local-alpine/x86_64/e2e-testpkg-1.0-r0.apk"), nil, "")
resp, body := doRequest(t, http.MethodGet, api("/api/v1/local/local-alpine/x86_64/e2e-testpkg-1.0-r0.apk"), nil, "")
if resp.StatusCode != http.StatusOK {
t.Fatalf("download apk: status %d: %s", resp.StatusCode, body)
}
@@ -191,3 +175,25 @@ func TestLocalAlpineIndex(t *testing.T) {
t.Fatalf("apk content mismatch")
}
}
func apkIndexText(t *testing.T, body []byte) string {
t.Helper()
zr, err := gzip.NewReader(bytes.NewReader(body))
if err != nil {
t.Fatalf("APKINDEX not gzip: %v", err)
}
tr := tar.NewReader(zr)
for {
hdr, err := tr.Next()
if err == io.EOF {
return ""
}
if err != nil {
t.Fatalf("APKINDEX not tar: %v", err)
}
if hdr.Name == "APKINDEX" {
b, _ := io.ReadAll(tr)
return string(b)
}
}
}
+7 -2
View File
@@ -17,13 +17,18 @@ import (
const puppetHelloMetadata = `{"name":"unkin-hello","version":"0.1.0","author":"unkin","summary":"e2e","license":"Apache-2.0","source":"https://example.invalid","dependencies":[{"name":"puppetlabs/stdlib","version_requirement":">= 4.0.0 < 10.0.0"}]}`
func puppetModuleTarball(t *testing.T) []byte {
t.Helper()
return puppetTarball(t, "unkin-hello-0.1.0", puppetHelloMetadata)
}
func puppetTarball(t *testing.T, dir, metadata string) []byte {
t.Helper()
var buf bytes.Buffer
zw := gzip.NewWriter(&buf)
tw := tar.NewWriter(zw)
for name, body := range map[string]string{
"unkin-hello-0.1.0/metadata.json": puppetHelloMetadata,
"unkin-hello-0.1.0/manifests/init.pp": "class hello {}\n",
dir + "/metadata.json": metadata,
dir + "/manifests/init.pp": "class stub {}\n",
} {
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: int64(len(body)), ModTime: time.Now()}); err != nil {
t.Fatal(err)
+83
View File
@@ -0,0 +1,83 @@
//go:build dockere2e
package e2edocker
import (
"fmt"
"net/http"
"os"
"os/exec"
"strings"
"testing"
"time"
)
// TestVirtualPuppetInstall puts unkin-hello and its stdlib dependency on two
// puppet locals and installs through a virtual over both with the real puppet
// module tool (dependencies resolved) and r10k, downloads 302ing to each owner.
func TestVirtualPuppetInstall(t *testing.T) {
network := os.Getenv("COMPOSE_NETWORK")
internal := strings.TrimRight(os.Getenv("ARTIFACTAPI_INTERNAL"), "/")
if network == "" || internal == "" {
t.Skip("COMPOSE_NETWORK/ARTIFACTAPI_INTERNAL not set; run via scripts/docker-e2e.sh")
}
if _, err := exec.LookPath("docker"); err != nil {
t.Skip("docker not available on the test host")
}
id := time.Now().UnixNano()
a, b, virt := fmt.Sprintf("e2e-puppet-a-%d", id), fmt.Sprintf("e2e-puppet-b-%d", id), fmt.Sprintf("e2e-puppet-v-%d", id)
modules := map[string]struct{ file, dir, meta string }{
a: {"unkin-hello-0.1.0.tar.gz", "unkin-hello-0.1.0", puppetHelloMetadata},
b: {"puppetlabs-stdlib-9.6.0.tar.gz", "puppetlabs-stdlib-9.6.0", `{"name":"puppetlabs-stdlib","version":"9.6.0","author":"puppetlabs","summary":"stub","license":"Apache-2.0","source":"https://example.invalid","dependencies":[]}`},
}
for repo, m := range modules {
createRepo(t, fmt.Sprintf(`{"name":%q,"package_type":"puppet","repo_type":"local"}`, repo))
defer deleteRepo(t, repo)
if resp, body := doRequest(t, http.MethodPut, api("/api/v2/remotes/"+repo+"/files/"+m.file), puppetTarball(t, m.dir, m.meta), "application/gzip"); resp.StatusCode != http.StatusCreated {
t.Fatalf("upload %s: %d %s", m.file, resp.StatusCode, body)
}
defer doRequest(t, http.MethodDelete, api("/api/v2/remotes/"+repo+"/files/v3/files/"+m.file), nil, "")
if resp, body := getEventually(t, api("/api/v1/local/"+repo+"/v3/releases/"+strings.TrimSuffix(m.file, ".tar.gz")), 15*time.Second); resp.StatusCode != http.StatusOK {
t.Fatalf("%s never indexed: %d %s", m.file, resp.StatusCode, body)
}
}
createVirtual(t, fmt.Sprintf(`{"name":%q,"package_type":"puppet","members":[%q,%q]}`, virt, a, b))
defer deleteVirtual(t, virt)
forge := internal + "/api/v1/virtual/" + virt
run := func(image, script string) string {
t.Helper()
out, err := exec.Command("docker", "run", "--rm", "--network", network, "--entrypoint", "sh", image, "-c", "set -eu; "+script).CombinedOutput()
if err != nil {
t.Fatalf("%s failed: %v\n%s", image, err, out)
}
return string(out)
}
out := run("ghcr.io/openvoxproject/openvoxagent:latest", "export PATH=/opt/puppetlabs/bin:$PATH; puppet module install unkin-hello --module_repository "+forge+" --target-dir /tmp/mods && puppet module list --color=false --modulepath /tmp/mods")
for _, want := range []string{"unkin-hello (v0.1.0)", "puppetlabs-stdlib (v9.6.0)"} {
if !strings.Contains(out, want) {
t.Fatalf("puppet module install missing %q:\n%s", want, out)
}
}
out = run("ghcr.io/voxpupuli/r10k:latest", "cd /tmp && printf 'forge:\\n baseurl: "+forge+"\\n' > r10k.yaml && "+
"printf \"mod 'unkin-hello', '0.1.0'\\nmod 'puppetlabs-stdlib', '9.6.0'\\n\" > Puppetfile && "+
"r10k puppetfile install -c r10k.yaml && cat modules/hello/metadata.json modules/stdlib/metadata.json")
if !strings.Contains(out, `"unkin-hello"`) || !strings.Contains(out, `"puppetlabs-stdlib"`) {
t.Fatalf("r10k did not install both modules:\n%s", out)
}
noRedirect := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
for repo, m := range modules {
resp, err := noRedirect.Get(api("/api/v1/virtual/" + virt + "/v3/files/" + m.file))
if err != nil {
t.Fatal(err)
}
_ = resp.Body.Close()
if loc := resp.Header.Get("Location"); resp.StatusCode != http.StatusFound || !strings.HasSuffix(loc, "/api/v1/local/"+repo+"/v3/files/"+m.file) {
t.Errorf("%s = %d %q, want 302 to %s", m.file, resp.StatusCode, loc, repo)
}
}
}
+1
View File
@@ -20,6 +20,7 @@ require (
github.com/testcontainers/testcontainers-go/modules/redis v0.42.0
github.com/ulikunitz/xz v0.5.16
golang.org/x/crypto v0.54.0
golang.org/x/mod v0.37.0
golang.org/x/sync v0.22.0
golang.org/x/time v0.15.0
gopkg.in/yaml.v3 v3.0.1
+4
View File
@@ -227,6 +227,8 @@ golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
@@ -247,6 +249,8 @@ golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+289
View File
@@ -0,0 +1,289 @@
package v1
import (
"bytes"
"cmp"
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"slices"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"git.unkin.net/unkin/artifactapi/internal/database"
"git.unkin.net/unkin/artifactapi/internal/provider/ansible"
"git.unkin.net/unkin/artifactapi/internal/virtual"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
var ansibleMaxPublishBytes int64 = 256 << 20
// ansibleError writes a Galaxy v3 error document, which ansible-galaxy prints.
func ansibleError(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]any{"errors": []map[string]string{{
"status": strconv.Itoa(status), "code": strings.ToLower(strings.ReplaceAll(http.StatusText(status), " ", "_")),
"title": http.StatusText(status), "detail": msg,
}}})
}
func writeAnsibleJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Vary", "Host, X-Forwarded-Proto")
w.WriteHeader(status)
enc := json.NewEncoder(w)
enc.SetEscapeHTML(false)
_ = enc.Encode(v)
}
func isAnsiblePublish(path string) bool {
return strings.TrimSuffix(path, "/")+"/" == ansible.PublishPath
}
func (h *ProxyHandler) handleAnsiblePublish(w http.ResponseWriter, r *http.Request) {
remote, err := h.db.GetRemote(r.Context(), chi.URLParam(r, "localName"))
if err != nil || remote.RepoType != models.RepoTypeLocal || remote.PackageType != models.PackageAnsible {
ansibleError(w, http.StatusMethodNotAllowed, "publish is only supported on local ansible repositories")
return
}
base, ok := requestBaseURL(w, r)
if !ok {
return
}
r.Body = http.MaxBytesReader(w, r.Body, ansibleMaxPublishBytes)
file, sum, err := readAnsibleUpload(r)
if err != nil {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
ansibleError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("upload exceeds %d bytes", ansibleMaxPublishBytes))
return
}
ansibleError(w, http.StatusBadRequest, err.Error())
return
}
digest := sha256.Sum256(file)
if got := hex.EncodeToString(digest[:]); sum != "" && !strings.EqualFold(sum, got) {
ansibleError(w, http.StatusBadRequest, "sha256 does not match the uploaded file")
return
}
e, err := ansible.ParseCollection(bytes.NewReader(file))
if err != nil {
ansibleError(w, http.StatusBadRequest, err.Error())
return
}
e.SHA256, e.Size, e.CreatedAt = hex.EncodeToString(digest[:]), int64(len(file)), time.Now().UTC()
id := e.Namespace + "." + e.Name + ":" + e.Version
unlock, err := h.engine.Lock(r.Context(), remote.Name, "_ansiblelock/"+e.TaskID(), gemsLockWait, 5*time.Minute)
if err != nil {
ansibleError(w, http.StatusServiceUnavailable, id+" is being published, retry")
return
}
defer unlock()
conflict := func() {
ansibleError(w, http.StatusConflict, id+" already exists; overwrites are not allowed")
}
if f, err := h.db.GetLocalFile(r.Context(), remote.Name, e.IndexPath()); err != nil {
ansibleError(w, http.StatusInternalServerError, err.Error())
return
} else if f != nil {
conflict()
return
}
// The version is not live, so a tarball left by a failed publish is safe to drop.
artifact := ansible.ArtifactPrefix + e.Filename()
fail := func(status int, msg string) {
if err := h.db.DeleteLocalFile(context.WithoutCancel(r.Context()), remote.Name, artifact); err != nil {
slog.Error("ansible publish cleanup failed", "repo", remote.Name, "path", artifact, "error", err)
}
ansibleError(w, status, msg)
}
if ok, err := h.npmStoreImmutable(r.Context(), remote.Name, artifact, file, "application/gzip"); err != nil {
fail(http.StatusInternalServerError, err.Error())
return
} else if !ok {
fail(http.StatusConflict, id+" already exists; overwrites are not allowed")
return
}
entry, err := json.Marshal(e)
if err != nil {
fail(http.StatusInternalServerError, err.Error())
return
}
// The index entry gates visibility, so it is written last.
if _, err := h.npmStore(r.Context(), remote.Name, e.IndexPath(), entry, "application/json", false); err != nil {
if errors.Is(err, database.ErrAlreadyExists) {
conflict()
return
}
fail(http.StatusInternalServerError, err.Error())
return
}
slog.Info("local ansible collection published", "repo", remote.Name, "collection", id)
repoBase := base + "/api/v1/local/" + url.PathEscape(remote.Name)
writeAnsibleJSON(w, http.StatusAccepted, map[string]string{"task": repoBase + "/api/v3/imports/collections/" + e.TaskID() + "/"})
}
// readAnsibleUpload returns the multipart "file" part, which ansible-galaxy
// base64-encodes, and the optional "sha256" field sent with it.
func readAnsibleUpload(r *http.Request) ([]byte, string, error) {
mr, err := r.MultipartReader()
if err != nil {
return nil, "", errors.New("expected a multipart/form-data upload")
}
var file []byte
var sum string
for {
part, err := mr.NextPart()
if err == io.EOF {
break
}
if err != nil {
return nil, "", err
}
switch part.FormName() {
case "file":
if file != nil {
return nil, "", errors.New("multiple file parts")
}
var src io.Reader = part
switch enc := strings.ToLower(part.Header.Get("Content-Transfer-Encoding")); enc {
case "base64":
src = base64.NewDecoder(base64.StdEncoding, part)
case "", "binary", "7bit", "8bit":
default:
return nil, "", fmt.Errorf("unsupported Content-Transfer-Encoding %q", enc)
}
if file, err = io.ReadAll(src); err != nil {
return nil, "", err
}
case "sha256":
b, err := io.ReadAll(io.LimitReader(part, 128))
if err != nil {
return nil, "", err
}
sum = strings.TrimSpace(string(b))
}
}
if file == nil {
return nil, "", errors.New("missing file part")
}
return file, sum, nil
}
// serveLocalAnsible answers the Galaxy v3 API of a local ansible repo.
func (h *ProxyHandler) serveLocalAnsible(w http.ResponseWriter, r *http.Request, remote *models.Remote, ap ansible.APIPath) {
base, ok := requestBaseURL(w, r)
if !ok {
return
}
body, err := h.localAnsibleDoc(r.Context(), remote.Name, ap, r.URL.Query(), base+"/api/v1/local/"+url.PathEscape(remote.Name))
if errors.Is(err, virtual.ErrNotFound) {
ansibleError(w, http.StatusNotFound, "not found")
return
}
if err != nil {
ansibleError(w, http.StatusInternalServerError, err.Error())
return
}
w.Header().Set("X-Artifact-Source", "local")
writeAnsibleJSON(w, http.StatusOK, body)
}
// LocalAnsible serves a local ansible repo's collection, full versions list
// or version detail to ansible virtuals, with root-relative URLs.
func (h *ProxyHandler) LocalAnsible(ctx context.Context, repo, path string) ([]byte, error) {
doc, err := h.localAnsibleDoc(ctx, repo, ansible.ParseAPIPath(path), nil, "/api/v1/local/"+url.PathEscape(repo))
if err != nil {
return nil, err
}
return json.Marshal(doc)
}
// localAnsibleDoc builds the document for ap; a nil q lists every version.
func (h *ProxyHandler) localAnsibleDoc(ctx context.Context, repo string, ap ansible.APIPath, q url.Values, base string) (any, error) {
switch ap.Kind {
case ansible.APIRoot:
return ansible.Root(), nil
case ansible.APIImport:
ns, rest, _ := strings.Cut(ap.TaskID, "-")
name, version, _ := strings.Cut(rest, "-")
entries, err := h.localAnsibleEntries(ctx, repo, ns, name)
if err != nil {
return nil, err
}
for _, e := range entries {
if e.Version == version {
return ansible.ImportTask(e), nil
}
}
case ansible.APICollection, ansible.APIVersions, ansible.APIVersion:
entries, err := h.localAnsibleEntries(ctx, repo, ap.Namespace, ap.Name)
if err != nil || len(entries) == 0 {
return nil, cmp.Or(err, virtual.ErrNotFound)
}
refs := make([]ansible.VersionRef, len(entries))
for i, e := range entries {
refs[i] = e.Ref()
}
switch ap.Kind {
case ansible.APICollection:
return ansible.CollectionDoc(base, ap.Namespace, ap.Name, refs), nil
case ansible.APIVersions:
limit, offset := len(refs), 0
if q != nil {
limit, offset = ansible.Page(q)
}
return ansible.VersionsPage(base, ap.Namespace, ap.Name, refs, limit, offset), nil
}
for _, e := range entries {
if e.Version == ap.Version {
return ansible.VersionDoc(base, e), nil
}
}
}
return nil, virtual.ErrNotFound
}
// localAnsibleEntries returns a collection's versions, newest publish first.
func (h *ProxyHandler) localAnsibleEntries(ctx context.Context, repo, ns, name string) ([]ansible.Entry, error) {
if !ansible.NameRe.MatchString(ns) || !ansible.NameRe.MatchString(name) {
return nil, nil
}
prefix := ansible.IndexPrefix + ns + "/" + name + "/"
files, err := h.db.ListLocalFilesByPrefix(ctx, repo, prefix)
if err != nil {
return nil, err
}
slices.SortFunc(files, func(a, b database.LocalFile) int { return cmp.Compare(b.ID, a.ID) })
var out []ansible.Entry
for _, f := range files {
// LIKE treats '_' as a wildcard, so the prefix match can over-select.
if !strings.HasPrefix(f.FilePath, prefix) {
continue
}
raw, err := h.readLocalBlob(ctx, f.ContentHash)
if err != nil {
return nil, err
}
var e ansible.Entry
if err := json.Unmarshal(raw, &e); err != nil {
return nil, fmt.Errorf("decode %s: %w", f.FilePath, err)
}
out = append(out, e)
}
return out, nil
}
+275
View File
@@ -0,0 +1,275 @@
package v1
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"encoding/base64"
"encoding/json"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/textproto"
"strings"
"testing"
"git.unkin.net/unkin/artifactapi/internal/virtual"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
func testCollection(t *testing.T, ns, name, version string) []byte {
t.Helper()
manifest := `{"collection_info":{"namespace":"` + ns + `","name":"` + name + `","version":"` + version + `","dependencies":{"ansible.posix":">=1.0.0"}}}`
var buf bytes.Buffer
zw := gzip.NewWriter(&buf)
tw := tar.NewWriter(zw)
_ = tw.WriteHeader(&tar.Header{Name: "MANIFEST.json", Mode: 0o644, Size: int64(len(manifest))})
_, _ = tw.Write([]byte(manifest))
_ = tw.Close()
_ = zw.Close()
return buf.Bytes()
}
// ansiblePublish uploads like ansible-galaxy: the file part base64-encoded
// whenever a sha256 field is sent, raw otherwise.
func ansiblePublish(t *testing.T, url string, tarball []byte, sum string) (int, string) {
t.Helper()
var body bytes.Buffer
mw := multipart.NewWriter(&body)
hdr := textproto.MIMEHeader{"Content-Disposition": {`form-data; name="file"; filename="c.tar.gz"`}}
data := tarball
if sum != "" {
_ = mw.WriteField("sha256", sum)
hdr.Set("Content-Transfer-Encoding", "base64")
data = []byte(base64.StdEncoding.EncodeToString(tarball))
}
fw, _ := mw.CreatePart(hdr)
_, _ = fw.Write(data)
_ = mw.Close()
req, _ := http.NewRequest(http.MethodPost, url, &body)
req.Header.Set("Content-Type", mw.FormDataContentType())
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
var b bytes.Buffer
_, _ = b.ReadFrom(resp.Body)
return resp.StatusCode, b.String()
}
func getJSON(t *testing.T, url string, v any) {
t.Helper()
code, body := npmDo(t, http.MethodGet, url, "")
if code != http.StatusOK {
t.Fatalf("GET %s = %d: %s", url, code, body)
}
if err := json.Unmarshal([]byte(body), v); err != nil {
t.Fatalf("GET %s: %v: %s", url, err, body)
}
}
func TestAnsiblePublishAndServe(t *testing.T) {
h, srv := npmTestServer(t)
repo := npmRepo(t, h, models.PackageAnsible)
base := srv.URL + "/api/v1/local/" + repo + "/"
publish := base + "api/v3/artifacts/collections/"
v1 := testCollection(t, "unkin", "e2e_hello", "1.0.0")
code, body := ansiblePublish(t, publish, v1, sha256Hex(v1))
if code != http.StatusAccepted {
t.Fatalf("publish = %d: %s", code, body)
}
var task struct{ Task string }
_ = json.Unmarshal([]byte(body), &task)
if task.Task != base+"api/v3/imports/collections/unkin-e2e_hello-1.0.0/" {
t.Fatalf("task = %q", task.Task)
}
var st map[string]any
getJSON(t, task.Task, &st)
if st["state"] != "completed" || st["finished_at"] == "" {
t.Fatalf("task = %v", st)
}
if code, _ := npmDo(t, http.MethodGet, base+"api/v3/imports/collections/unkin-e2e_hello-9.9.9/", ""); code != http.StatusNotFound {
t.Errorf("unknown task = %d, want 404", code)
}
for _, v := range []string{"1.1.0", "2.0.0"} {
tb := testCollection(t, "unkin", "e2e_hello", v)
if code, body := ansiblePublish(t, publish, tb, ""); code != http.StatusAccepted {
t.Fatalf("publish %s = %d: %s", v, code, body)
}
}
if code, body := ansiblePublish(t, publish, v1, ""); code != http.StatusConflict || !strings.Contains(body, "already exists") {
t.Errorf("identical republish = %d: %s, want 409", code, body)
}
if code, _ := ansiblePublish(t, publish, testCollection(t, "unkin", "e2e_hello", "1.0.0")[:20], ""); code != http.StatusBadRequest {
t.Errorf("truncated tarball = %d, want 400", code)
}
var root map[string]map[string]string
getJSON(t, base+"api/", &root)
if root["available_versions"]["v3"] != "v3/" {
t.Fatalf("root = %v", root)
}
var page struct {
Meta struct{ Count int }
Links struct{ Next, Previous *string }
Data []struct{ Version, Href string }
}
getJSON(t, base+"api/v3/collections/unkin/e2e_hello/versions/?limit=2", &page)
if page.Meta.Count != 3 || len(page.Data) != 2 || page.Data[0].Version != "2.0.0" || page.Links.Next == nil || page.Links.Previous != nil {
t.Fatalf("page 1 = %+v", page)
}
next := *page.Links.Next
if !strings.HasPrefix(next, "/api/v1/local/"+repo+"/") {
t.Fatalf("next = %q, want root-relative", next)
}
next = srv.URL + next
page.Links.Next = nil
getJSON(t, next, &page)
if len(page.Data) != 1 || page.Data[0].Version != "1.0.0" || page.Links.Next != nil {
t.Fatalf("page 2 (%s) = %+v", next, page)
}
var detail struct {
DownloadURL string `json:"download_url"`
Artifact struct{ Sha256 string }
Metadata struct{ Dependencies map[string]string }
Namespace struct{ Name string }
Collection struct{ Name string }
}
getJSON(t, page.Data[0].Href, &detail)
if detail.DownloadURL != base+"artifacts/unkin-e2e_hello-1.0.0.tar.gz" || detail.Artifact.Sha256 != sha256Hex(v1) ||
detail.Metadata.Dependencies["ansible.posix"] != ">=1.0.0" || detail.Namespace.Name != "unkin" || detail.Collection.Name != "e2e_hello" {
t.Fatalf("detail = %+v", detail)
}
if code, got := npmDo(t, http.MethodGet, detail.DownloadURL, ""); code != http.StatusOK || got != string(v1) {
t.Fatalf("download = %d (%d bytes)", code, len(got))
}
var coll map[string]any
getJSON(t, base+"api/v3/collections/unkin/e2e_hello/", &coll)
if coll["versions_url"] != base+"api/v3/collections/unkin/e2e_hello/versions/" || coll["updated_at"] == "" {
t.Fatalf("collection = %v", coll)
}
for _, p := range []string{"api/v3/collections/unkin/nope/", "api/v3/collections/unkin/e2e_hello/versions/9.0.0/", "_index/unkin/e2e_hello/1.0.0", "other"} {
if code, _ := npmDo(t, http.MethodGet, base+p, ""); code != http.StatusNotFound {
t.Errorf("GET %s = %d, want 404", p, code)
}
}
}
func TestAnsiblePublishRejects(t *testing.T) {
h, srv := npmTestServer(t)
repo := npmRepo(t, h, models.PackageAnsible)
publish := srv.URL + "/api/v1/local/" + repo + "/api/v3/artifacts/collections/"
tb := testCollection(t, "unkin", "rej", "1.0.0")
if code, _ := ansiblePublish(t, publish, tb, strings.Repeat("0", 64)); code != http.StatusBadRequest {
t.Errorf("sha256 mismatch = %d, want 400", code)
}
if code, _ := npmDo(t, http.MethodPost, publish, "raw"); code != http.StatusBadRequest {
t.Errorf("non-multipart = %d, want 400", code)
}
old := ansibleMaxPublishBytes
ansibleMaxPublishBytes = 64
if code, _ := ansiblePublish(t, publish, tb, ""); code != http.StatusRequestEntityTooLarge {
t.Errorf("oversize = %d, want 413", code)
}
ansibleMaxPublishBytes = old
generic := npmRepo(t, h, models.PackageGeneric)
if code, _ := ansiblePublish(t, srv.URL+"/api/v1/local/"+generic+"/api/v3/artifacts/collections/", tb, ""); code != http.StatusMethodNotAllowed {
t.Errorf("publish to generic local = %d, want 405", code)
}
if code, _ := ansiblePublish(t, publish, tb, ""); code != http.StatusAccepted {
t.Errorf("publish after rejects = %d, want 202", code)
}
}
func TestAnsibleLocalPerHost(t *testing.T) {
h, srv := npmTestServer(t)
repo := npmRepo(t, h, models.PackageAnsible)
if code, b := ansiblePublish(t, srv.URL+"/api/v1/local/"+repo+"/api/v3/artifacts/collections/", testCollection(t, "unkin", "host", "1.0.0"), ""); code != http.StatusAccepted {
t.Fatalf("publish = %d: %s", code, b)
}
routes := h.Routes()
path := "/local/" + repo + "/api/v3/collections/unkin/host/versions/1.0.0/"
r := httptest.NewRequest(http.MethodGet, path, nil)
r.Host = "x@evil.com"
w := httptest.NewRecorder()
routes.ServeHTTP(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("bad Host = %d, want 400", w.Code)
}
r = httptest.NewRequest(http.MethodGet, path, nil)
r.Host = "aa.example"
r.Header.Set("X-Forwarded-Proto", "https")
w = httptest.NewRecorder()
routes.ServeHTTP(w, r)
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"download_url":"https://aa.example/api/v1/local/`+repo+`/artifacts/unkin-host-1.0.0.tar.gz"`) {
t.Fatalf("detail = %d: %s", w.Code, w.Body)
}
if cc, vary := w.Header().Get("Cache-Control"), w.Header().Get("Vary"); cc != "no-store" || vary != "Host, X-Forwarded-Proto" {
t.Errorf("Cache-Control %q Vary %q", cc, vary)
}
}
func TestAnsibleVirtualOverLocals(t *testing.T) {
h, srv := npmTestServer(t)
h.virtualEngine = virtual.NewEngine(h.db, nil)
h.virtualEngine.AnsibleLocal = h.LocalAnsible
a, b := npmRepo(t, h, models.PackageAnsible), npmRepo(t, h, models.PackageAnsible)
for _, p := range []struct{ repo, version string }{{a, "1.0.0"}, {b, "1.0.0"}, {b, "2.0.0"}} {
if code, body := ansiblePublish(t, srv.URL+"/api/v1/local/"+p.repo+"/api/v3/artifacts/collections/", testCollection(t, "unkin", "virt", p.version), ""); code != http.StatusAccepted {
t.Fatalf("publish %s to %s = %d: %s", p.version, p.repo, code, body)
}
}
virt := "v" + a
if err := h.db.CreateVirtual(context.Background(), &models.Virtual{Name: virt, PackageType: models.PackageAnsible, Members: []string{a, b}}); err != nil {
t.Fatal(err)
}
base := srv.URL + "/api/v1/virtual/" + virt + "/"
var page struct {
Meta struct{ Count int }
Links struct{ Next *string }
Data []struct{ Version, Href string }
}
getJSON(t, base+"api/v3/collections/unkin/virt/versions/?limit=1", &page)
if page.Meta.Count != 2 || len(page.Data) != 1 || page.Data[0].Version != "1.0.0" || page.Links.Next == nil {
t.Fatalf("page 1 = %+v", page)
}
href := page.Data[0].Href
if !strings.HasPrefix(*page.Links.Next, "/api/v1/virtual/"+virt+"/") {
t.Fatalf("next = %q, want root-relative", *page.Links.Next)
}
getJSON(t, srv.URL+*page.Links.Next, &page)
if len(page.Data) != 1 || page.Data[0].Version != "2.0.0" || page.Links.Next != nil {
t.Fatalf("page 2 = %+v", page)
}
var detail struct {
DownloadURL string `json:"download_url"`
}
getJSON(t, href, &detail)
if detail.DownloadURL != base+a+"/artifacts/unkin-virt-1.0.0.tar.gz" {
t.Fatalf("download_url = %q, want the first member via the virtual", detail.DownloadURL)
}
noFollow := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
resp, err := noFollow.Get(detail.DownloadURL)
if err != nil {
t.Fatal(err)
}
_ = resp.Body.Close()
if resp.StatusCode != http.StatusFound || resp.Header.Get("Location") != srv.URL+"/api/v1/local/"+a+"/artifacts/unkin-virt-1.0.0.tar.gz" {
t.Fatalf("download = %d -> %q", resp.StatusCode, resp.Header.Get("Location"))
}
for _, p := range []string{"api/v3/collections/unkin/nope/", "notamember/x.tar.gz"} {
if code, _ := npmDo(t, http.MethodGet, base+p, ""); code != http.StatusNotFound {
t.Errorf("GET %s = %d, want 404", p, code)
}
}
}
+64
View File
@@ -0,0 +1,64 @@
package v1
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"net/url"
"git.unkin.net/unkin/artifactapi/internal/provider/ansible"
"git.unkin.net/unkin/artifactapi/internal/virtual"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
// serveVirtualAnsible serves the merged Galaxy v3 API and 302s downloads to
// the member owning that version.
func (h *ProxyHandler) serveVirtualAnsible(w http.ResponseWriter, r *http.Request, virt models.Virtual, path, proxyBaseURL string) {
ap := ansible.ParseAPIPath(path)
base := proxyBaseURL + "/api/v1/virtual/" + url.PathEscape(virt.Name)
var doc any
var err error
switch ap.Kind {
case ansible.APIRoot:
doc = ansible.Root()
case ansible.APIVersion:
var body []byte
if body, err = h.virtualEngine.AnsibleVersion(r.Context(), virt, ap.Namespace, ap.Name, ap.Version, proxyBaseURL); err == nil {
doc = json.RawMessage(body)
}
case ansible.APICollection, ansible.APIVersions:
var d *virtual.AnsibleDoc
if d, err = h.virtualEngine.AnsibleVersions(r.Context(), virt, ap.Namespace, ap.Name); err == nil {
if ap.Kind == ansible.APICollection {
doc = ansible.CollectionDoc(base, ap.Namespace, ap.Name, d.Refs)
} else {
limit, offset := ansible.Page(r.URL.Query())
doc = ansible.VersionsPage(base, ap.Namespace, ap.Name, d.Refs, limit, offset)
}
}
default:
loc, ok, err := h.virtualEngine.MemberRedirect(r.Context(), virt, path, r.URL.RawQuery, proxyBaseURL)
switch {
case err != nil:
ansibleError(w, http.StatusBadRequest, err.Error())
case !ok:
ansibleError(w, http.StatusNotFound, "not found")
default:
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Vary", "Host, X-Forwarded-Proto")
http.Redirect(w, r, loc, http.StatusFound)
}
return
}
switch {
case errors.Is(err, virtual.ErrNotFound):
ansibleError(w, http.StatusNotFound, "not found")
case err != nil:
slog.Error("virtual ansible fetch failed", "virtual", virt.Name, "path", path, "error", err)
ansibleError(w, http.StatusBadGateway, "bad gateway")
default:
w.Header().Set("X-Artifact-Source", "virtual")
writeAnsibleJSON(w, http.StatusOK, doc)
}
}
+58
View File
@@ -0,0 +1,58 @@
package v1
import (
"net/url"
"strings"
"testing"
"git.unkin.net/unkin/artifactapi/internal/provider"
)
type plainProvider struct{ provider.Provider }
type forwardingProvider struct{ plainProvider }
func digits(v string) (string, bool) {
if v == "" || strings.Trim(v, "0123456789") != "" {
return "", false
}
return strings.TrimLeft(v, "0"), true
}
func (forwardingProvider) ForwardedQuery(path string) map[string]provider.QueryValidator {
if strings.HasSuffix(path, ".tar.gz") {
return nil
}
return map[string]provider.QueryValidator{"limit": digits, "offset": digits}
}
func TestForwardQuery(t *testing.T) {
tests := []struct {
name string
prov provider.Provider
path string
query string
want string
}{
{"non-forwarder keeps path", plainProvider{}, "a/versions/", "limit=10&offset=5", "a/versions/"},
{"no query", forwardingProvider{}, "a/versions/", "", "a/versions/"},
{"allowlisted params sorted", forwardingProvider{}, "a/versions/", "offset=5&limit=10", "a/versions/?limit=10&offset=5"},
{"unknown params dropped", forwardingProvider{}, "a/versions/", "x=1&limit=10&cb=zz", "a/versions/?limit=10"},
{"only unknown params", forwardingProvider{}, "a/versions/", "x=1", "a/versions/"},
{"first value wins", forwardingProvider{}, "a/versions/", "limit=10&limit=20", "a/versions/?limit=10"},
{"invalid value dropped", forwardingProvider{}, "a/versions/", "limit=1%202&offset=5", "a/versions/?offset=5"},
{"value canonicalised", forwardingProvider{}, "a/versions/", "limit=0010", "a/versions/?limit=10"},
{"path without allowlist", forwardingProvider{}, "a/b-1.0.0.tar.gz", "limit=10", "a/b-1.0.0.tar.gz"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
q, err := url.ParseQuery(tt.query)
if err != nil {
t.Fatal(err)
}
if got := forwardQuery(tt.prov, tt.path, q); got != tt.want {
t.Errorf("forwardQuery = %q, want %q", got, tt.want)
}
})
}
}
+4
View File
@@ -64,6 +64,10 @@ func (h *ProxyHandler) lockGemVersion(w http.ResponseWriter, r *http.Request, re
}
func (h *ProxyHandler) handleLocalPost(w http.ResponseWriter, r *http.Request) {
if isAnsiblePublish(chi.URLParam(r, "*")) {
h.handleAnsiblePublish(w, r)
return
}
if chi.URLParam(r, "*") != "api/v1/gems" {
gemsText(w, http.StatusNotFound, "not found")
return
+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 (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
v2 "git.unkin.net/unkin/artifactapi/internal/api/v2"
"git.unkin.net/unkin/artifactapi/internal/database"
"git.unkin.net/unkin/artifactapi/internal/provider"
"git.unkin.net/unkin/artifactapi/internal/provider/ansible"
"git.unkin.net/unkin/artifactapi/internal/proxy"
"git.unkin.net/unkin/artifactapi/internal/storage"
"git.unkin.net/unkin/artifactapi/internal/virtual"
@@ -115,11 +119,11 @@ func (h *ProxyHandler) handleProxy(w http.ResponseWriter, r *http.Request) {
}
func (h *ProxyHandler) serveFetch(w http.ResponseWriter, r *http.Request, remote *models.Remote, prov provider.Provider, path string) {
result, err := h.engine.Fetch(r.Context(), *remote, path, prov, r.Header)
result, err := h.engine.Fetch(r.Context(), *remote, forwardQuery(prov, path, r.URL.Query()), prov, r.Header)
if err != nil {
var proxyErr *proxy.ProxyError
if errors.As(err, &proxyErr) {
http.Error(w, proxyErr.Message, proxyErr.Status)
writeProxyError(w, proxyErr)
return
}
slog.Error("proxy fetch failed", "remote", remote.Name, "path", path, "error", err)
@@ -147,6 +151,24 @@ func (h *ProxyHandler) serveFetch(w http.ResponseWriter, r *http.Request, remote
io.Copy(w, body)
}
// writeProxyError answers a quarantine refusal as JSON and anything else as text.
func writeProxyError(w http.ResponseWriter, pe *proxy.ProxyError) {
if pe.RetryAfter > 0 {
w.Header().Set("Retry-After", strconv.Itoa(pe.RetryAfter))
}
if pe.Published.IsZero() {
http.Error(w, pe.Message, pe.Status)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(pe.Status)
_ = json.NewEncoder(w).Encode(map[string]string{
"error": pe.Message,
"published": pe.Published.Format(time.RFC3339),
"available": pe.Available.Format(time.RFC3339),
})
}
func rewritesMutable(prov provider.Provider, path string) bool {
_, sr := prov.(provider.ServeRewriter)
_, rr := prov.(provider.ResponseRewriter)
@@ -174,7 +196,7 @@ func (h *ProxyHandler) handleProxyHead(w http.ResponseWriter, r *http.Request) {
if err != nil {
var proxyErr *proxy.ProxyError
if errors.As(err, &proxyErr) {
http.Error(w, proxyErr.Message, proxyErr.Status)
writeProxyError(w, proxyErr)
return
}
slog.Error("proxy head failed", "remote", remote.Name, "path", path, "error", err)
@@ -209,6 +231,18 @@ func (h *ProxyHandler) handleVirtual(w http.ResponseWriter, r *http.Request) {
h.serveVirtualGems(w, r, *virt, path, proxyBaseURL)
return
}
if virt.PackageType == models.PackageAnsible {
h.serveVirtualAnsible(w, r, *virt, path, proxyBaseURL)
return
}
if virt.PackageType == models.PackagePuppet {
h.serveVirtualPuppet(w, r, *virt, path, proxyBaseURL)
return
}
if virt.PackageType == models.PackageGoProxy {
h.serveVirtualGoProxy(w, r, *virt, path)
return
}
loc, ok, err := h.virtualEngine.MemberRedirect(r.Context(), *virt, path, r.URL.RawQuery, proxyBaseURL)
if err != nil {
@@ -274,6 +308,17 @@ func (h *ProxyHandler) handleLocal(w http.ResponseWriter, r *http.Request) {
}
}
if remote.PackageType == models.PackageAnsible {
if ap := ansible.ParseAPIPath(path); ap.Kind != ansible.APIUnknown {
h.serveLocalAnsible(w, r, remote, ap)
return
}
if !strings.HasPrefix(path, ansible.ArtifactPrefix) {
http.Error(w, "not found", http.StatusNotFound)
return
}
}
prov, _ := provider.Get(remote.PackageType)
if indexer, ok := prov.(provider.LocalIndexer); ok {
if indexer.ServeLocalIndex(w, r, h.db, remote.Name, path) {
@@ -325,6 +370,25 @@ func Scheme(r *http.Request) string {
return "http"
}
// forwardQuery appends the provider's validated query params to path in a
// canonical (sorted, single-valued) form, since the result is the cache key.
func forwardQuery(prov provider.Provider, path string, query url.Values) string {
f, ok := prov.(provider.QueryForwarder)
if !ok {
return path
}
kept := url.Values{}
for name, valid := range f.ForwardedQuery(path) {
if v, ok := valid(query.Get(name)); ok {
kept.Set(name, v)
}
}
if len(kept) == 0 {
return path
}
return path + "?" + kept.Encode()
}
// rewriteServed rewrites a cached body for the requesting host and marks the
// response uncacheable by shared caches, since it varies per Host.
func rewriteServed(w http.ResponseWriter, r *http.Request, sr provider.ServeRewriter, remote models.Remote, src io.Reader) ([]byte, bool) {
+16
View File
@@ -0,0 +1,16 @@
package v1
import (
"net/http/httptest"
"testing"
"git.unkin.net/unkin/artifactapi/internal/proxy"
)
func TestWriteProxyErrorRetryAfter(t *testing.T) {
w := httptest.NewRecorder()
writeProxyError(w, &proxy.ProxyError{Status: 503, Message: "quarantine check unavailable", RetryAfter: 30})
if w.Code != 503 || w.Header().Get("Retry-After") != "30" {
t.Errorf("code %d, Retry-After %q", w.Code, w.Header().Get("Retry-After"))
}
}
+77
View File
@@ -0,0 +1,77 @@
package v1
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"strings"
"git.unkin.net/unkin/artifactapi/internal/provider/puppet"
"git.unkin.net/unkin/artifactapi/internal/virtual"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
// serveVirtualPuppet serves the merged Forge v3 module and release listings
// and 302s release details and downloads to the member owning that release.
func (h *ProxyHandler) serveVirtualPuppet(w http.ResponseWriter, r *http.Request, virt models.Virtual, path, proxyBaseURL string) {
var doc any
var err error
switch {
case path == "v3/releases":
q := r.URL.Query()
slug := puppet.NormalizeSlug(q.Get("module"))
if !puppet.ValidSlug(slug) {
forgeError(w, http.StatusBadRequest, "module is required")
return
}
var d *virtual.PuppetDoc
d, err = h.virtualEngine.PuppetReleases(r.Context(), virt, slug)
releases := []any{}
if errors.Is(err, virtual.ErrNotFound) {
err = nil
} else if err == nil {
for _, rel := range d.Releases {
releases = append(releases, rel)
}
}
doc = puppet.ReleasesPage(slug, releases, q)
case strings.HasPrefix(path, "v3/modules/"):
slug := puppet.NormalizeSlug(strings.TrimPrefix(path, "v3/modules/"))
if !puppet.ValidSlug(slug) {
puppet.NotFound(w)
return
}
var d *virtual.PuppetDoc
if d, err = h.virtualEngine.PuppetReleases(r.Context(), virt, slug); err == nil {
doc, err = virtual.PuppetModule(d)
}
default:
var loc string
loc, err = h.virtualEngine.PuppetRedirect(r.Context(), virt, path, proxyBaseURL)
if err == nil {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Vary", "Host, X-Forwarded-Proto")
http.Redirect(w, r, loc, http.StatusFound)
return
}
}
switch {
case errors.Is(err, virtual.ErrNotFound):
puppet.NotFound(w)
case errors.Is(err, virtual.ErrBadPath):
forgeError(w, http.StatusBadRequest, err.Error())
case err != nil:
slog.Error("virtual puppet fetch failed", "virtual", virt.Name, "path", path, "error", err)
forgeError(w, http.StatusBadGateway, "bad gateway")
default:
w.Header().Set("X-Artifact-Source", "virtual")
puppet.WriteJSON(w, doc)
}
}
func forgeError(w http.ResponseWriter, code int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(map[string]any{"message": http.StatusText(code), "errors": []string{msg}})
}
+90
View File
@@ -0,0 +1,90 @@
package v1
import (
"context"
"net/http"
"strings"
"testing"
"git.unkin.net/unkin/artifactapi/internal/provider"
"git.unkin.net/unkin/artifactapi/internal/virtual"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
func TestPuppetVirtualOverLocals(t *testing.T) {
h, srv := npmTestServer(t)
h.virtualEngine = virtual.NewEngine(h.db, nil)
a, b := npmRepo(t, h, models.PackagePuppet), npmRepo(t, h, models.PackagePuppet)
for _, p := range []struct{ repo, version string }{{a, "0.1.0"}, {b, "0.1.0"}, {b, "0.2.0"}} {
file := "v3/files/unkin-hello-" + p.version + ".tar.gz"
meta := &provider.PuppetMetadata{RepoName: p.repo, FilePath: file, ContentHash: "sha256:00", Slug: "unkin-hello", Version: p.version, MetadataJSON: `{"name":"unkin-hello","version":"` + p.version + `"}`}
if err := h.db.InsertPuppetMetadata(context.Background(), meta); err != nil {
t.Fatal(err)
}
}
virt := "v" + a
if err := h.db.CreateVirtual(context.Background(), &models.Virtual{Name: virt, PackageType: models.PackagePuppet, Members: []string{a, b}}); err != nil {
t.Fatal(err)
}
base := srv.URL + "/api/v1/virtual/" + virt
var page struct {
Pagination struct {
Total int
Next *string
}
Results []struct {
Version string
FileURI string `json:"file_uri"`
}
}
getJSON(t, base+"/v3/releases?module=unkin/hello&limit=1", &page)
if page.Pagination.Total != 2 || len(page.Results) != 1 || page.Results[0].Version != "0.2.0" || page.Pagination.Next == nil {
t.Fatalf("page 1 = %+v", page)
}
if *page.Pagination.Next != "/v3/releases?limit=1&module=unkin-hello&offset=1" || page.Results[0].FileURI != "/v3/files/unkin-hello-0.2.0.tar.gz" {
t.Fatalf("links not relative to the forge root: next %q file_uri %q", *page.Pagination.Next, page.Results[0].FileURI)
}
getJSON(t, base+*page.Pagination.Next, &page)
if len(page.Results) != 1 || page.Results[0].Version != "0.1.0" || page.Pagination.Next != nil {
t.Fatalf("page 2 = %+v", page)
}
var mod struct {
CurrentRelease struct{ Version string } `json:"current_release"`
Releases []struct{ Version string }
}
getJSON(t, base+"/v3/modules/unkin-hello", &mod)
if mod.CurrentRelease.Version != "0.2.0" || len(mod.Releases) != 2 {
t.Fatalf("module = %+v", mod)
}
noRedirect := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
for p, want := range map[string]string{
"/v3/files/unkin-hello-0.1.0.tar.gz": "/api/v1/local/" + a + "/v3/files/unkin-hello-0.1.0.tar.gz",
"/v3/releases/unkin-hello-0.2.0": "/api/v1/local/" + b + "/v3/releases/unkin-hello-0.2.0",
} {
resp, err := noRedirect.Get(base + p)
if err != nil {
t.Fatal(err)
}
_ = resp.Body.Close()
if resp.StatusCode != http.StatusFound || resp.Header.Get("Location") != srv.URL+want || resp.Header.Get("Cache-Control") != "no-store" {
t.Errorf("%s = %d %q %q, want 302 to %s", p, resp.StatusCode, resp.Header.Get("Location"), resp.Header.Get("Cache-Control"), want)
}
}
getJSON(t, base+"/v3/releases?module=unkin-absent", &page)
if page.Pagination.Total != 0 || len(page.Results) != 0 {
t.Fatalf("absent module = %+v", page)
}
for p, want := range map[string]int{
"/v3/releases": http.StatusBadRequest,
"/v3/modules/unkin-absent": http.StatusNotFound,
"/v3/files/unkin-hello-0.9.0.tar.gz": http.StatusNotFound,
} {
if code, body := npmDo(t, http.MethodGet, base+p, ""); code != want || !strings.Contains(body, `"errors"`) {
t.Errorf("%s = %d %s, want %d", p, code, body, want)
}
}
}
+38 -1
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"io"
"net/http"
"os"
"github.com/go-chi/chi/v5"
@@ -83,7 +84,22 @@ func (h *LocalHandler) uploadValidated(w http.ResponseWriter, r *http.Request, r
return
}
result, err := h.cas.Store(r.Context(), r.Body, contentType)
var body io.Reader = r.Body
if cv, ok := prov.(provider.ContentValidator); ok {
f, err := spoolBody(r.Body)
if err != nil {
http.Error(w, fmt.Sprintf("read upload: %v", err), http.StatusInternalServerError)
return
}
defer discard(f)
if err := cv.ValidateContent(storagePath, f.Name()); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
body = f
}
result, err := h.cas.Store(r.Context(), body, contentType)
if err != nil {
http.Error(w, fmt.Sprintf("store failed: %v", err), http.StatusInternalServerError)
return
@@ -110,6 +126,27 @@ func (h *LocalHandler) uploadValidated(w http.ResponseWriter, r *http.Request, r
writeJSON(w, http.StatusCreated, uploader.UploadResponse(storagePath, result.ContentHash, result.SizeBytes))
}
func spoolBody(r io.Reader) (*os.File, error) {
f, err := os.CreateTemp("", "artifactapi-upload-*")
if err != nil {
return nil, err
}
if _, err := io.Copy(f, r); err != nil {
discard(f)
return nil, err
}
if _, err := f.Seek(0, io.SeekStart); err != nil {
discard(f)
return nil, err
}
return f, nil
}
func discard(f *os.File) {
_ = f.Close()
_ = os.Remove(f.Name())
}
func (h *LocalHandler) uploadGeneric(w http.ResponseWriter, r *http.Request, remote *models.Remote, filePath string) {
existing, err := h.db.GetLocalFile(r.Context(), remote.Name, filePath)
if err != nil {
+55
View File
@@ -0,0 +1,55 @@
package v2
import (
"context"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"git.unkin.net/unkin/artifactapi/internal/database"
_ "git.unkin.net/unkin/artifactapi/internal/provider/goproxy"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
// TestLocalUploadContentRejected verifies a ContentValidator provider rejects
// bad bytes with 400 before anything is stored.
func TestLocalUploadContentRejected(t *testing.T) {
if testDSN == "" {
t.Skip("Docker unavailable")
}
ctx := context.Background()
db, err := database.New(testDSN)
if err != nil {
t.Fatal(err)
}
defer db.Close()
const repo = "go-local-validate"
if err := db.CreateRemote(ctx, &models.Remote{Name: repo, PackageType: models.PackageGoProxy, RepoType: models.RepoTypeLocal}); err != nil {
t.Fatal(err)
}
router := chi.NewRouter()
router.Route("/remotes/{name}/files", func(r chi.Router) {
r.Put("/*", NewLocalHandler(db, nil).Routes().ServeHTTP)
})
cases := map[string]string{
"example.unkin.net/hello/@v/v0.1.0.zip": "not a zip",
"example.unkin.net/hello/@v/v0.1.0.mod": "module example.unkin.net/other\n",
"example.unkin.net/hello/@v/v0.1.0.info": `{"Version":"v9.9.9"}`,
"example.unkin.net/Hello/@v/v0.1.0.mod": "module example.unkin.net/Hello\n",
}
for path, body := range cases {
w := httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest("PUT", "/remotes/"+repo+"/files/"+path, strings.NewReader(body)))
if w.Code != 400 {
t.Errorf("%s: status %d, want 400 (%s)", path, w.Code, w.Body.String())
}
if f, _ := db.GetLocalFile(ctx, repo, path); f != nil {
t.Errorf("%s: rejected upload was recorded", path)
}
}
}
+18 -1
View File
@@ -72,7 +72,7 @@ func (h *RemotesHandler) get(w http.ResponseWriter, r *http.Request) {
const defaultMutableTTL = 3600
func (h *RemotesHandler) create(w http.ResponseWriter, r *http.Request) {
remote := models.Remote{MutableTTL: defaultMutableTTL}
remote := models.Remote{MutableTTL: defaultMutableTTL, QuarantineDays: models.DefaultQuarantineDays}
if err := json.NewDecoder(r.Body).Decode(&remote); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
@@ -104,6 +104,14 @@ func (h *RemotesHandler) create(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := remote.ValidateSyncInterval(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := remote.ValidateQuarantine(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := h.db.CreateRemote(r.Context(), &remote); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@@ -126,6 +134,7 @@ func (h *RemotesHandler) update(w http.ResponseWriter, r *http.Request) {
var remote models.Remote
if oldKnown {
remote.MutableTTL = existing.MutableTTL
remote.QuarantineDays = existing.QuarantineDays
}
if err := json.NewDecoder(r.Body).Decode(&remote); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
@@ -144,6 +153,14 @@ func (h *RemotesHandler) update(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := remote.ValidateSyncInterval(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := remote.ValidateQuarantine(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := h.db.UpdateRemote(r.Context(), &remote); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@@ -0,0 +1,57 @@
package v2
import (
"encoding/json"
"net/http/httptest"
"testing"
"git.unkin.net/unkin/artifactapi/internal/database"
)
func TestCacheRepodataRoundTrip(t *testing.T) {
if testDSN == "" {
t.Skip("Docker unavailable")
}
db, err := database.New(testDSN)
if err != nil {
t.Fatal(err)
}
defer db.Close()
h := NewRemotesHandler(db, nil, nil).Routes()
get := func() bool {
t.Helper()
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest("GET", "/cache-rd", nil))
var got struct {
CacheRepodata *bool `json:"cache_repodata"`
}
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil || got.CacheRepodata == nil {
t.Fatalf("get = %d %s", w.Code, w.Body.String())
}
return *got.CacheRepodata
}
const body = `"package_type":"github_rpm","base_url":"https://api.github.com"`
steps := []struct {
method, path, json string
want bool
}{
{"POST", "/", `{"name":"cache-rd",` + body + `}`, false},
{"PUT", "/cache-rd", `{` + body + `,"cache_repodata":true}`, true},
{"PUT", "/cache-rd", `{` + body + `,"cache_repodata":false}`, false},
}
for i, s := range steps {
if c := do(t, h, s.method, s.path, s.json); c/100 != 2 {
t.Fatalf("step %d %s %s = %d", i, s.method, s.path, c)
}
if got := get(); got != s.want {
t.Errorf("step %d: cache_repodata = %v, want %v", i, got, s.want)
}
}
if c := do(t, h, "POST", "/", `{"name":"cache-rd-on",`+body+`,"cache_repodata":true}`); c != 201 {
t.Fatalf("create with cache_repodata = %d", c)
}
if r, err := db.GetRemote(t.Context(), "cache-rd-on"); err != nil || !r.CacheRepodata {
t.Fatalf("create did not store cache_repodata: %v %v", r, err)
}
}
+117
View File
@@ -55,3 +55,120 @@ func TestMutableTTLOmittedVsZero(t *testing.T) {
t.Errorf("explicit 0 stored as %d", got)
}
}
// sync_interval round-trips through the API and DB on github_* remotes and is
// rejected when negative, over the maximum, or on a type without a background syncer.
func TestSyncIntervalAPI(t *testing.T) {
if testDSN == "" {
t.Skip("Docker unavailable")
}
db, err := database.New(testDSN)
if err != nil {
t.Fatal(err)
}
defer db.Close()
h := NewRemotesHandler(db, nil, nil).Routes()
interval := func(name string) int {
t.Helper()
r, err := db.GetRemote(context.Background(), name)
if err != nil {
t.Fatalf("get %s: %v", name, err)
}
return r.SyncInterval
}
const gh = `"package_type":"github_deb","base_url":"https://api.github.com/repos/acme/tools","releases_remote":"github"`
if c := do(t, h, "POST", "/", `{"name":"si-gh",`+gh+`}`); c != 201 {
t.Fatalf("create unset = %d", c)
}
if got := interval("si-gh"); got != 0 {
t.Fatalf("unset sync_interval stored as %d", got)
}
if c := do(t, h, "PUT", "/si-gh", `{`+gh+`,"sync_interval":3600}`); c != 200 {
t.Fatalf("update = %d", c)
}
if got := interval("si-gh"); got != 3600 {
t.Fatalf("sync_interval = %d, want 3600", got)
}
for _, body := range []string{
`{"name":"si-neg",` + gh + `,"sync_interval":-1}`,
`{"name":"si-max",` + gh + `,"sync_interval":2592001}`,
`{"name":"si-int32",` + gh + `,"sync_interval":2147483648}`,
`{"name":"si-rpm","package_type":"rpm","base_url":"https://example.com/repo","sync_interval":60}`,
} {
if c := do(t, h, "POST", "/", body); c != 400 {
t.Errorf("%s = %d, want 400", body, c)
}
}
if c := do(t, h, "PUT", "/si-gh", `{`+gh+`,"sync_interval":-1}`); c != 400 {
t.Errorf("update negative = %d, want 400", c)
}
if got := interval("si-gh"); got != 3600 {
t.Errorf("rejected update changed sync_interval to %d", got)
}
}
func TestQuarantineDaysDefault(t *testing.T) {
if testDSN == "" {
t.Skip("Docker unavailable")
}
db, err := database.New(testDSN)
if err != nil {
t.Fatal(err)
}
defer db.Close()
h := NewRemotesHandler(db, nil, nil).Routes()
days := func() int {
t.Helper()
r, err := db.GetRemote(context.Background(), "q-days")
if err != nil {
t.Fatal(err)
}
return r.QuarantineDays
}
const body = `"package_type":"npm","base_url":"https://example.com","quarantine_enabled":true`
for i, s := range []struct {
method, path, json string
want int
}{
{"POST", "/", `{"name":"q-days",` + body + `}`, 3},
{"PUT", "/q-days", `{` + body + `,"quarantine_days":14}`, 14},
{"PUT", "/q-days", `{` + body + `}`, 14},
} {
if c := do(t, h, s.method, s.path, s.json); c/100 != 2 {
t.Fatalf("step %d = %d", i, c)
}
if got := days(); got != s.want {
t.Errorf("step %d: quarantine_days = %d, want %d", i, got, s.want)
}
}
}
func TestQuarantineDaysValidation(t *testing.T) {
if testDSN == "" {
t.Skip("Docker unavailable")
}
db, err := database.New(testDSN)
if err != nil {
t.Fatal(err)
}
defer db.Close()
h := NewRemotesHandler(db, nil, nil).Routes()
const body = `"package_type":"npm","base_url":"https://example.com"`
for i, s := range []struct {
method, path, json string
want int
}{
{"POST", "/", `{"name":"q-val",` + body + `,"quarantine_enabled":true,"quarantine_days":0}`, 400},
{"POST", "/", `{"name":"q-val",` + body + `,"quarantine_enabled":true,"quarantine_days":-1}`, 400},
{"POST", "/", `{"name":"q-val",` + body + `,"quarantine_days":0}`, 201},
{"PUT", "/q-val", `{` + body + `,"quarantine_enabled":true}`, 400},
{"PUT", "/q-val", `{` + body + `,"quarantine_enabled":true,"quarantine_days":1}`, 200},
{"PUT", "/q-val", `{` + body + `,"quarantine_enabled":true,"quarantine_days":0}`, 400},
} {
if c := do(t, h, s.method, s.path, s.json); c != s.want {
t.Errorf("step %d %s %s = %d, want %d", i, s.method, s.path, c, s.want)
}
}
}
+56
View File
@@ -0,0 +1,56 @@
package v2
import (
"encoding/json"
"net/http/httptest"
"testing"
"git.unkin.net/unkin/artifactapi/internal/database"
)
func TestVirtualParallelFetchRoundTrip(t *testing.T) {
if testDSN == "" {
t.Skip("Docker unavailable")
}
db, err := database.New(testDSN)
if err != nil {
t.Fatal(err)
}
defer db.Close()
h := NewVirtualsHandler(db).Routes()
parallel := func() bool {
t.Helper()
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest("GET", "/vpf", nil))
var got map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil || w.Code != 200 {
t.Fatalf("get = %d %s", w.Code, w.Body)
}
v, ok := got["parallel_fetch"].(bool)
if !ok {
t.Fatalf("parallel_fetch missing from %s", w.Body)
}
return v
}
const body = `"package_type":"rpm","members":["a"]`
steps := []struct {
method, path, json string
want bool
}{
{"POST", "/", `{"name":"vpf",` + body + `}`, false},
{"PUT", "/vpf", `{` + body + `,"parallel_fetch":true}`, true},
{"PUT", "/vpf", `{` + body + `,"parallel_fetch":false}`, false},
}
for i, s := range steps {
if c := do(t, h, s.method, s.path, s.json); c/100 != 2 {
t.Fatalf("step %d %s %s = %d", i, s.method, s.path, c)
}
if got := parallel(); got != s.want {
t.Errorf("step %d: parallel_fetch = %v, want %v", i, got, s.want)
}
}
if c := do(t, h, "DELETE", "/vpf", ""); c != 204 {
t.Fatalf("delete = %d", c)
}
}
+93 -17
View File
@@ -2,7 +2,9 @@ package auth
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"slices"
@@ -104,42 +106,116 @@ func (b *BearerOIDC) discoverOnce(done chan struct{}) (v *oidc.IDTokenVerifier,
// Require gates every request through next on a valid token. A nil receiver
// passes everything through.
func (b *BearerOIDC) Require(next http.Handler) http.Handler {
return b.require(next, "Bearer")
}
func (b *BearerOIDC) require(next http.Handler, challenge string) http.Handler {
if b == nil {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
scheme, raw, _ := strings.Cut(strings.TrimSpace(r.Header.Get("Authorization")), " ")
raw = strings.TrimSpace(raw)
if !strings.EqualFold(scheme, "Bearer") || raw == "" {
unauthorized(w, "missing bearer token")
raw := token(r)
if raw == "" {
unauthorized(w, challenge, "missing token")
return
}
v, err := b.getVerifier(r.Context())
if err != nil {
sub, err := b.verify(r.Context(), raw)
if errors.Is(err, errIssuerUnavailable) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write([]byte(`{"error":"token issuer unavailable"}`))
return
}
tok, err := v.Verify(r.Context(), raw)
if err != nil {
slog.Info("bearer token rejected", "error", err)
unauthorized(w, "invalid token")
unauthorized(w, challenge, "invalid token")
return
}
if len(b.audiences) > 0 && !slices.ContainsFunc(tok.Audience, func(a string) bool { return slices.Contains(b.audiences, a) }) {
slog.Info("bearer token rejected", "error", "audience not allowed", "aud", tok.Audience)
unauthorized(w, "invalid token")
return
}
slog.Info("bearer token accepted", "sub", tok.Subject, "method", r.Method, "path", r.URL.Path)
slog.Info("bearer token accepted", "sub", sub, "method", r.Method, "path", r.URL.Path)
next.ServeHTTP(w, r)
})
}
func (b *BearerOIDC) verify(ctx context.Context, raw string) (string, error) {
v, err := b.getVerifier(ctx)
if err != nil {
return "", errIssuerUnavailable
}
tok, err := v.Verify(ctx, raw)
if err != nil {
return "", err
}
if len(b.audiences) > 0 && !slices.ContainsFunc(tok.Audience, func(a string) bool { return slices.Contains(b.audiences, a) }) {
return "", fmt.Errorf("audience %v not allowed", tok.Audience)
}
return tok.Subject, nil
}
// token reads the JWT from `Bearer`/`Token` credentials, the password of
// `Basic` credentials (any username), or a bare header value as `gem push`
// sends its API key.
func token(r *http.Request) string {
if _, pass, ok := r.BasicAuth(); ok {
return pass
}
scheme, raw, found := strings.Cut(strings.TrimSpace(r.Header.Get("Authorization")), " ")
if !found {
return scheme
}
if strings.EqualFold(scheme, "Bearer") || strings.EqualFold(scheme, "Token") {
return strings.TrimSpace(raw)
}
return ""
}
// RequireWrites gates only mutating methods.
func (b *BearerOIDC) RequireWrites(next http.Handler) http.Handler {
gated := b.Require(next)
return writesOnly(next, b.Require(next))
}
// RequireRegistryWrites gates Docker Registry V2 writes behind the token flow
// Docker clients need: an unauthenticated ping gets a Bearer challenge naming
// realm, which serves the Basic password back as the token once verified, or
// an anonymous token so pulls stay open.
func (b *BearerOIDC) RequireRegistryWrites(next http.Handler, realm func(*http.Request) string) http.Handler {
if b == nil {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
challenge := `Bearer realm="` + realm(r) + `",service="artifactapi"`
hasAuth := r.Header.Get("Authorization") != ""
switch {
case r.URL.Path == "/v2/token":
writeToken(w, b.registryToken(r))
case r.URL.Path == "/v2/" && !hasAuth:
unauthorized(w, challenge, "authentication required")
default:
writesOnly(next, b.require(next, challenge)).ServeHTTP(w, r)
}
})
}
// registryToken echoes a valid credential back as the registry token. Missing
// or invalid credentials (e.g. an expired `docker login`) get the anonymous
// token so pulls keep working; writes are still verified per request.
func (b *BearerOIDC) registryToken(r *http.Request) string {
raw := token(r)
if raw == "" {
return "anonymous"
}
if _, err := b.verify(r.Context(), raw); err != nil {
slog.Info("registry credential rejected, issuing anonymous token", "error", err)
return "anonymous"
}
return raw
}
func writeToken(w http.ResponseWriter, tok string) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"token": tok, "access_token": tok})
}
func writesOnly(next, gated http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
@@ -150,9 +226,9 @@ func (b *BearerOIDC) RequireWrites(next http.Handler) http.Handler {
})
}
func unauthorized(w http.ResponseWriter, msg string) {
func unauthorized(w http.ResponseWriter, challenge, msg string) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("WWW-Authenticate", "Bearer")
w.Header().Set("WWW-Authenticate", challenge)
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"` + msg + `"}`))
}
+88
View File
@@ -8,6 +8,7 @@ import (
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
@@ -253,3 +254,90 @@ func TestRequireWritesLeavesReadsOpen(t *testing.T) {
}
}
}
func basic(user, pass string) string {
return "Basic " + base64.StdEncoding.EncodeToString([]byte(user+":"+pass))
}
func TestTokenSchemes(t *testing.T) {
iss := testsupport.NewOIDCIssuer()
defer iss.Close()
h := NewBearerOIDC(iss.URL, []string{"artifactapi"}).RequireWrites(ok)
valid := iss.Token(nil, "artifactapi", "ci", time.Hour)
cases := []struct {
name string
authz string
want int
}{
{"bearer", "Bearer " + valid, http.StatusNoContent},
{"token scheme", "Token " + valid, http.StatusNoContent},
{"basic password", basic("anyone", valid), http.StatusNoContent},
{"basic empty user", basic("", valid), http.StatusNoContent},
{"bare jwt", valid, http.StatusNoContent},
{"basic wrong aud", basic("ci", iss.Token(nil, "nope", "ci", time.Hour)), http.StatusUnauthorized},
{"basic token as user", basic(valid, "x"), http.StatusUnauthorized},
{"bare garbage", "garbage", http.StatusUnauthorized},
{"unknown scheme", "Digest " + valid, http.StatusUnauthorized},
}
for _, c := range cases {
if got := serveAuth(h, http.MethodPut, c.authz); got != c.want {
t.Errorf("%s: status %d, want %d", c.name, got, c.want)
}
}
}
func TestRequireRegistryWrites(t *testing.T) {
iss := testsupport.NewOIDCIssuer()
defer iss.Close()
h := NewBearerOIDC(iss.URL, []string{"artifactapi"}).RequireRegistryWrites(ok, func(*http.Request) string { return "https://reg.example/v2/token" })
valid := iss.Token(nil, "artifactapi", "ci", time.Hour)
do := func(method, path, authz string) *httptest.ResponseRecorder {
r := httptest.NewRequest(method, path, nil)
if authz != "" {
r.Header.Set("Authorization", authz)
}
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w
}
challenge := `Bearer realm="https://reg.example/v2/token",service="artifactapi"`
w := do(http.MethodGet, "/v2/", "")
if w.Code != http.StatusUnauthorized || w.Header().Get("WWW-Authenticate") != challenge {
t.Errorf("anonymous ping: %d %q", w.Code, w.Header().Get("WWW-Authenticate"))
}
if w := do(http.MethodGet, "/v2/", "Bearer anonymous"); w.Code != http.StatusNoContent {
t.Errorf("ping with token: %d", w.Code)
}
if w := do(http.MethodGet, "/v2/token", ""); w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"token":"anonymous"`) {
t.Errorf("anonymous token: %d %s", w.Code, w.Body)
}
if w := do(http.MethodGet, "/v2/token", basic("ci", valid)); w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"token":"`+valid+`"`) {
t.Errorf("token exchange: %d %s", w.Code, w.Body)
}
expired := iss.Token(nil, "artifactapi", "ci", -time.Minute)
for _, authz := range []string{basic("ci", "bogus"), basic("ci", expired), "Bearer " + expired} {
if w := do(http.MethodGet, "/v2/token", authz); w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"token":"anonymous"`) {
t.Errorf("rejected credential %q: %d %s", authz, w.Code, w.Body)
}
}
for _, m := range []string{http.MethodGet, http.MethodHead} {
if w := do(m, "/v2/repo/img/manifests/t", ""); w.Code != http.StatusNoContent {
t.Errorf("anonymous %s: %d", m, w.Code)
}
}
for _, m := range []string{http.MethodPost, http.MethodPatch, http.MethodPut, http.MethodDelete} {
w := do(m, "/v2/repo/img/blobs/uploads/", "Bearer anonymous")
if w.Code != http.StatusUnauthorized || w.Header().Get("WWW-Authenticate") != challenge {
t.Errorf("anonymous %s: %d %q", m, w.Code, w.Header().Get("WWW-Authenticate"))
}
for _, authz := range []string{"Bearer " + valid, basic("ci", valid)} {
if w := do(m, "/v2/repo/img/blobs/uploads/", authz); w.Code != http.StatusNoContent {
t.Errorf("authenticated %s: %d", m, w.Code)
}
}
}
if got := serve(NewBearerOIDC("", nil).RequireRegistryWrites(ok, nil), http.MethodPut, ""); got != http.StatusNoContent {
t.Errorf("disabled: status %d", got)
}
}
+14
View File
@@ -84,6 +84,20 @@ func (r *Redis) GetETag(ctx context.Context, remote, path string) (string, error
return val, err
}
// FirstSeen records now as the first sighting of path unless one is already
// stored, and returns the stored sighting.
func (r *Redis) FirstSeen(ctx context.Context, remote, path string, ttl time.Duration) (time.Time, error) {
key := fmt.Sprintf("firstseen:%s:%s", remote, path)
if err := r.client.SetNX(ctx, key, time.Now().Unix(), ttl).Err(); err != nil {
return time.Time{}, err
}
sec, err := r.client.Get(ctx, key).Int64()
if err != nil {
return time.Time{}, err
}
return time.Unix(sec, 0), nil
}
func (r *Redis) GetToken(ctx context.Context, key string) (string, error) {
val, err := r.client.Get(ctx, "token:"+key).Result()
if err == redis.Nil {
+10
View File
@@ -61,6 +61,8 @@ type Config struct {
// Bearer-JWT auth on configuration writes; empty issuer disables it.
OIDCIssuer string
OIDCAudiences []string
// AuthUploads extends that auth to local repo uploads, deletes and evictions.
AuthUploads bool
}
func (c *Config) DatabaseDSN() string {
@@ -105,6 +107,13 @@ func Load() (*Config, error) {
if oidcIssuer != "" && len(oidcAudiences) == 0 {
return nil, fmt.Errorf("ARTIFACTAPI_OIDC_AUDIENCE is required when ARTIFACTAPI_OIDC_ISSUER is set")
}
authUploads, err := strconv.ParseBool(getenv("ARTIFACTAPI_AUTH_UPLOADS", "false"))
if err != nil {
return nil, fmt.Errorf("invalid ARTIFACTAPI_AUTH_UPLOADS: %w", err)
}
if authUploads && oidcIssuer == "" {
return nil, fmt.Errorf("ARTIFACTAPI_OIDC_ISSUER is required when ARTIFACTAPI_AUTH_UPLOADS is set")
}
cfg := &Config{
ListenAddr: getenv("LISTEN_ADDR", ":8000"),
@@ -142,6 +151,7 @@ func Load() (*Config, error) {
OIDCIssuer: oidcIssuer,
OIDCAudiences: oidcAudiences,
AuthUploads: authUploads,
}
return cfg, nil
+20
View File
@@ -81,3 +81,23 @@ func TestLoadOIDC(t *testing.T) {
t.Error("expected error for issuer without audience")
}
}
func TestLoadAuthUploads(t *testing.T) {
cfg, err := Load()
if err != nil || cfg.AuthUploads {
t.Fatalf("default: %v, AuthUploads=%v", err, cfg.AuthUploads)
}
t.Setenv("ARTIFACTAPI_AUTH_UPLOADS", "true")
if _, err := Load(); err == nil {
t.Error("expected error for AUTH_UPLOADS without issuer")
}
t.Setenv("ARTIFACTAPI_OIDC_ISSUER", "https://vault.example/v1/identity/oidc")
t.Setenv("ARTIFACTAPI_OIDC_AUDIENCE", "artifactapi")
if cfg, err := Load(); err != nil || !cfg.AuthUploads {
t.Fatalf("enabled: %v, AuthUploads=%v", err, cfg.AuthUploads)
}
t.Setenv("ARTIFACTAPI_AUTH_UPLOADS", "maybe")
if _, err := Load(); err == nil {
t.Error("expected error for invalid ARTIFACTAPI_AUTH_UPLOADS")
}
}
+2 -2
View File
@@ -30,8 +30,8 @@ func (db *DB) ListGitHubAlpineRemotes(ctx context.Context) ([]models.Remote, err
// ClaimGitHubAlpineSyncLease atomically claims the per-remote github_alpine sync
// lease. See claimSyncLease.
func (db *DB) ClaimGitHubAlpineSyncLease(ctx context.Context, remoteName, owner string, freshness, lease time.Duration) (bool, string, error) {
return db.claimSyncLease(ctx, "github_alpine_sync_state", remoteName, owner, freshness, lease)
func (db *DB) ClaimGitHubAlpineSyncLease(ctx context.Context, remoteName, owner string, freshness, lease, interval, phase time.Duration) (bool, string, error) {
return db.claimSyncLease(ctx, "github_alpine_sync_state", remoteName, owner, freshness, lease, interval, phase)
}
// ReleaseGitHubAlpineSyncLease records a github_alpine scan outcome and frees the
+111 -2
View File
@@ -79,13 +79,19 @@ func TestRemotesCRUD(t *testing.T) {
if err != nil || got.BaseURL != "https://example.com" {
t.Fatalf("get: %v %v", got, err)
}
if got.CacheRepodata {
t.Error("cache_repodata should default to false")
}
got.BaseURL = "https://updated.example.com"
got.CacheRepodata = true
got.SyncInterval = 600
got.ServeStale = true
if err := testDB.UpdateRemote(ctx(), got); err != nil {
t.Fatalf("update: %v", err)
}
got, _ = testDB.GetRemote(ctx(), "r-crud")
if got.BaseURL != "https://updated.example.com" {
t.Errorf("update not applied: %v", got.BaseURL)
if got.BaseURL != "https://updated.example.com" || !got.CacheRepodata || got.SyncInterval != 600 || !got.ServeStale {
t.Errorf("update not applied: %v %v %v %v", got.BaseURL, got.CacheRepodata, got.SyncInterval, got.ServeStale)
}
list, err := testDB.ListRemotes(ctx())
if err != nil || len(list) == 0 {
@@ -307,6 +313,68 @@ func TestLocalFiles(t *testing.T) {
}
}
// A repo recreated under a deleted repo's name must not inherit its files or
// metadata, or re-uploading the same path 409s.
func TestDeleteRemoteDropsLocalFiles(t *testing.T) {
requireDB(t)
seedRemote(t, "r-recreate")
hash := seedBlob(t, "recreatehash")
if err := testDB.CreateLocalFile(ctx(), "r-recreate", "x86_64/a-1.0-r0.apk", hash); err != nil {
t.Fatal(err)
}
if err := testDB.InsertAlpineMetadata(ctx(), &provider.AlpineMetadata{RepoName: "r-recreate", FilePath: "x86_64/a-1.0-r0.apk", ContentHash: hash, Name: "a", Version: "1.0-r0", Arch: "x86_64"}); err != nil {
t.Fatal(err)
}
if err := testDB.DeleteRemote(ctx(), "r-recreate"); err != nil {
t.Fatal(err)
}
seedRemote(t, "r-recreate")
defer testDB.DeleteRemote(ctx(), "r-recreate")
if f, err := testDB.GetLocalFile(ctx(), "r-recreate", "x86_64/a-1.0-r0.apk"); err != nil || f != nil {
t.Fatalf("recreated repo inherited local file: %v %v", f, err)
}
if rows, err := testDB.ListAlpineMetadataEntries(ctx(), "r-recreate"); err != nil || len(rows) != 0 {
t.Fatalf("recreated repo inherited alpine metadata: %v %v", rows, err)
}
if err := testDB.CreateLocalFile(ctx(), "r-recreate", "x86_64/a-1.0-r0.apk", hash); err != nil {
t.Fatalf("re-upload after recreate: %v", err)
}
}
func TestVirtualParallelFetchRoundTrip(t *testing.T) {
requireDB(t)
if err := testDB.CreateVirtual(ctx(), &models.Virtual{
Name: "v-parallel", PackageType: models.PackageRPM, Members: []string{"a"}, ParallelFetch: true,
}); err != nil {
t.Fatal(err)
}
defer func() { _ = testDB.DeleteVirtual(ctx(), "v-parallel") }()
v, err := testDB.GetVirtual(ctx(), "v-parallel")
if err != nil || !v.ParallelFetch {
t.Fatalf("created parallel_fetch = %v, %v; want true", v, err)
}
v.ParallelFetch = false
if err := testDB.UpdateVirtual(ctx(), v); err != nil {
t.Fatal(err)
}
vs, err := testDB.ListVirtuals(ctx())
if err != nil {
t.Fatal(err)
}
for _, l := range vs {
if l.Name == "v-parallel" && l.ParallelFetch {
t.Fatal("updated parallel_fetch not persisted")
}
}
if err := testDB.CreateVirtual(ctx(), &models.Virtual{Name: "v-serial", PackageType: models.PackageRPM, Members: []string{"a"}}); err != nil {
t.Fatal(err)
}
defer func() { _ = testDB.DeleteVirtual(ctx(), "v-serial") }()
if v, err := testDB.GetVirtual(ctx(), "v-serial"); err != nil || v.ParallelFetch {
t.Fatalf("default parallel_fetch = %v, %v; want false", v, err)
}
}
func TestVirtualsCRUD(t *testing.T) {
requireDB(t)
if err := testDB.CreateVirtual(ctx(), &models.Virtual{
@@ -425,3 +493,44 @@ func TestRPMMetadata(t *testing.T) {
t.Fatalf("list rpm rows: %v %v", len(rows), err)
}
}
func TestRPMMetadataFingerprint(t *testing.T) {
requireDB(t)
seedRemote(t, "r-rpm-fp")
fp := func() [2]int64 {
t.Helper()
c, m, err := testDB.RPMMetadataFingerprint(ctx(), "r-rpm-fp")
if err != nil {
t.Fatal(err)
}
return [2]int64{c, m}
}
insert := func(path string) {
t.Helper()
if err := testDB.InsertRPMMetadata(ctx(), &provider.RPMMetadata{
RepoName: "r-rpm-fp", FilePath: path, ContentHash: "sha256:" + path,
Name: path, Version: "1", Release: "1", Arch: "noarch",
}); err != nil {
t.Fatal(err)
}
}
if got := fp(); got != [2]int64{0, 0} {
t.Fatalf("empty fingerprint = %v", got)
}
insert("a.rpm")
one := fp()
insert("a.rpm")
if fp() != one {
t.Fatal("conflicting insert changed fingerprint")
}
insert("b.rpm")
two := fp()
if err := testDB.DeleteRPMMetadata(ctx(), "r-rpm-fp", "a.rpm"); err != nil {
t.Fatal(err)
}
afterDelete := fp()
if one[0] != 1 || two[0] != 2 || afterDelete[0] != 1 || afterDelete == one || afterDelete == two {
t.Fatalf("fingerprints did not track changes: %v %v %v", one, two, afterDelete)
}
}
+2 -2
View File
@@ -30,8 +30,8 @@ func (db *DB) ListGitHubDebRemotes(ctx context.Context) ([]models.Remote, error)
// ClaimGitHubDebSyncLease atomically claims the per-remote github_deb sync
// lease. See claimSyncLease.
func (db *DB) ClaimGitHubDebSyncLease(ctx context.Context, remoteName, owner string, freshness, lease time.Duration) (bool, string, error) {
return db.claimSyncLease(ctx, "github_deb_sync_state", remoteName, owner, freshness, lease)
func (db *DB) ClaimGitHubDebSyncLease(ctx context.Context, remoteName, owner string, freshness, lease, interval, phase time.Duration) (bool, string, error) {
return db.claimSyncLease(ctx, "github_deb_sync_state", remoteName, owner, freshness, lease, interval, phase)
}
// ReleaseGitHubDebSyncLease records a github_deb scan outcome and frees the
+4 -4
View File
@@ -29,7 +29,7 @@ func TestGitHubDebSyncLease(t *testing.T) {
const lease = 15 * time.Minute
freshness := time.Hour
claimed, etag, err := testDB.ClaimGitHubDebSyncLease(ctx(), name, "replica-1", freshness, lease)
claimed, etag, err := testDB.ClaimGitHubDebSyncLease(ctx(), name, "replica-1", freshness, lease, 0, 0)
if err != nil || !claimed {
t.Fatalf("replica-1 first claim: claimed=%v err=%v", claimed, err)
}
@@ -37,7 +37,7 @@ func TestGitHubDebSyncLease(t *testing.T) {
t.Fatalf("initial etag should be empty, got %q", etag)
}
claimed2, _, err := testDB.ClaimGitHubDebSyncLease(ctx(), name, "replica-2", freshness, lease)
claimed2, _, err := testDB.ClaimGitHubDebSyncLease(ctx(), name, "replica-2", freshness, lease, 0, 0)
if err != nil {
t.Fatalf("replica-2 claim err: %v", err)
}
@@ -49,7 +49,7 @@ func TestGitHubDebSyncLease(t *testing.T) {
t.Fatalf("release: %v", err)
}
claimed3, _, err := testDB.ClaimGitHubDebSyncLease(ctx(), name, "replica-2", freshness, lease)
claimed3, _, err := testDB.ClaimGitHubDebSyncLease(ctx(), name, "replica-2", freshness, lease, 0, 0)
if err != nil {
t.Fatalf("replica-2 recency claim err: %v", err)
}
@@ -57,7 +57,7 @@ func TestGitHubDebSyncLease(t *testing.T) {
t.Fatal("periodic claim succeeded inside the freshness window")
}
claimed4, etag4, err := testDB.ClaimGitHubDebSyncLease(ctx(), name, "replica-2", 0, lease)
claimed4, etag4, err := testDB.ClaimGitHubDebSyncLease(ctx(), name, "replica-2", 0, lease, 0, 0)
if err != nil || !claimed4 {
t.Fatalf("prime claim: claimed=%v err=%v", claimed4, err)
}
+19 -9
View File
@@ -34,8 +34,8 @@ func (db *DB) ListGitHubRPMRemotes(ctx context.Context) ([]models.Remote, error)
// ClaimGitHubSyncLease atomically claims the per-remote github_rpm sync lease.
// See claimSyncLease.
func (db *DB) ClaimGitHubSyncLease(ctx context.Context, remoteName, owner string, freshness, lease time.Duration) (bool, string, error) {
return db.claimSyncLease(ctx, "github_rpm_sync_state", remoteName, owner, freshness, lease)
func (db *DB) ClaimGitHubSyncLease(ctx context.Context, remoteName, owner string, freshness, lease, interval, phase time.Duration) (bool, string, error) {
return db.claimSyncLease(ctx, "github_rpm_sync_state", remoteName, owner, freshness, lease, interval, phase)
}
// ReleaseGitHubSyncLease records a github_rpm scan outcome and frees the lease.
@@ -48,22 +48,32 @@ func (db *DB) ReleaseGitHubSyncLease(ctx context.Context, remoteName, owner stri
// (claimed=true) only when the remote is due and no live lease is held by
// another replica. Due means: a pending retry after a failed scan has reached
// next_retry_at, or, with no retry pending, the remote was never synced or was
// synced longer than freshness ago. A zero freshness (prime scans) ignores the
// recency gate but still honours a pending retry's backoff. The returned etag is
// the stored releases-list ETag, shared across replicas so a conditional request
// last synced before the current slot (interval set) or longer than freshness
// ago (otherwise). With interval set, slots start at phase past each multiple
// of interval and a remote created inside the current slot with no state row
// waits for the next.
// Slots are computed from the DB clock so replicas agree whatever their own
// clocks say. A zero freshness and interval (prime scans) ignores the recency
// gate but still honours a pending retry's backoff. The returned etag is the
// stored releases-list ETag, shared across replicas so a conditional request
// can short-circuit an unchanged repo.
func (db *DB) claimSyncLease(ctx context.Context, table, remoteName, owner string, freshness, lease time.Duration) (bool, string, error) {
func (db *DB) claimSyncLease(ctx context.Context, table, remoteName, owner string, freshness, lease, interval, phase time.Duration) (bool, string, error) {
const slot = `to_timestamp(floor((extract(epoch FROM now())::float8 - $6::float8) / NULLIF($5::float8, 0)) * $5::float8 + $6::float8)`
row := db.Pool.QueryRow(ctx, `
INSERT INTO `+table+` AS s (remote_name, sync_lease_owner, sync_lease_expires)
VALUES ($1, $2, now() + make_interval(secs => $4))
SELECT r.name, $2, now() + make_interval(secs => $4)
FROM remotes r
WHERE r.name = $1
AND ($5::float8 <= 0 OR COALESCE(r.created_at, '-infinity') < `+slot+`
OR EXISTS (SELECT 1 FROM `+table+` e WHERE e.remote_name = r.name))
ON CONFLICT (remote_name) DO UPDATE
SET sync_lease_owner = $2,
sync_lease_expires = now() + make_interval(secs => $4)
WHERE (CASE WHEN s.next_retry_at IS NOT NULL THEN s.next_retry_at <= now()
ELSE s.last_synced_at IS NULL OR s.last_synced_at < now() - make_interval(secs => $3) END)
ELSE s.last_synced_at IS NULL OR s.last_synced_at < COALESCE(`+slot+`, now() - make_interval(secs => $3)) END)
AND (s.sync_lease_expires IS NULL OR s.sync_lease_expires < now())
RETURNING s.etag
`, remoteName, owner, freshness.Seconds(), lease.Seconds())
`, remoteName, owner, freshness.Seconds(), lease.Seconds(), interval.Seconds(), phase.Seconds())
var etag string
if err := row.Scan(&etag); err != nil {
+219 -8
View File
@@ -1,6 +1,10 @@
package database
import (
"context"
"fmt"
"math"
"sync/atomic"
"testing"
"time"
@@ -30,7 +34,7 @@ func TestGitHubSyncLease(t *testing.T) {
freshness := time.Hour
// First claim on a never-synced remote wins; etag starts empty.
claimed, etag, err := testDB.ClaimGitHubSyncLease(ctx(), name, "replica-1", freshness, lease)
claimed, etag, err := testDB.ClaimGitHubSyncLease(ctx(), name, "replica-1", freshness, lease, 0, 0)
if err != nil || !claimed {
t.Fatalf("replica-1 first claim: claimed=%v err=%v", claimed, err)
}
@@ -39,7 +43,7 @@ func TestGitHubSyncLease(t *testing.T) {
}
// A second replica cannot claim while the lease is held.
claimed2, _, err := testDB.ClaimGitHubSyncLease(ctx(), name, "replica-2", freshness, lease)
claimed2, _, err := testDB.ClaimGitHubSyncLease(ctx(), name, "replica-2", freshness, lease, 0, 0)
if err != nil {
t.Fatalf("replica-2 claim err: %v", err)
}
@@ -53,7 +57,7 @@ func TestGitHubSyncLease(t *testing.T) {
}
// A periodic re-claim inside the freshness window is blocked by recency.
claimed3, _, err := testDB.ClaimGitHubSyncLease(ctx(), name, "replica-2", freshness, lease)
claimed3, _, err := testDB.ClaimGitHubSyncLease(ctx(), name, "replica-2", freshness, lease, 0, 0)
if err != nil {
t.Fatalf("replica-2 recency claim err: %v", err)
}
@@ -62,7 +66,7 @@ func TestGitHubSyncLease(t *testing.T) {
}
// A prime (freshness 0) bypasses recency and reads the persisted etag.
claimed4, etag4, err := testDB.ClaimGitHubSyncLease(ctx(), name, "replica-2", 0, lease)
claimed4, etag4, err := testDB.ClaimGitHubSyncLease(ctx(), name, "replica-2", 0, lease, 0, 0)
if err != nil || !claimed4 {
t.Fatalf("prime claim: claimed=%v err=%v", claimed4, err)
}
@@ -108,7 +112,7 @@ func TestGitHubSyncLeaseFailureBackoff(t *testing.T) {
freshness := time.Hour
claim := func(f time.Duration) (bool, string) {
t.Helper()
ok, etag, err := testDB.ClaimGitHubSyncLease(ctx(), name, "r1", f, lease)
ok, etag, err := testDB.ClaimGitHubSyncLease(ctx(), name, "r1", f, lease, 0, 0)
if err != nil {
t.Fatalf("claim: %v", err)
}
@@ -220,21 +224,21 @@ func TestGitHubSyncStateDeletedWithRemote(t *testing.T) {
}{
{"github_rpm_sync_state", models.PackageGitHubRPM,
func(n string) (bool, string, error) {
return testDB.ClaimGitHubSyncLease(ctx(), n, "r1", time.Hour, time.Minute)
return testDB.ClaimGitHubSyncLease(ctx(), n, "r1", time.Hour, time.Minute, 0, 0)
},
func(n string, res provider.SyncResult) error {
return testDB.ReleaseGitHubSyncLease(ctx(), n, "r1", res)
}},
{"github_deb_sync_state", models.PackageGitHubDeb,
func(n string) (bool, string, error) {
return testDB.ClaimGitHubDebSyncLease(ctx(), n, "r1", time.Hour, time.Minute)
return testDB.ClaimGitHubDebSyncLease(ctx(), n, "r1", time.Hour, time.Minute, 0, 0)
},
func(n string, res provider.SyncResult) error {
return testDB.ReleaseGitHubDebSyncLease(ctx(), n, "r1", res)
}},
{"github_alpine_sync_state", models.PackageGitHubAlpine,
func(n string) (bool, string, error) {
return testDB.ClaimGitHubAlpineSyncLease(ctx(), n, "r1", time.Hour, time.Minute)
return testDB.ClaimGitHubAlpineSyncLease(ctx(), n, "r1", time.Hour, time.Minute, 0, 0)
},
func(n string, res provider.SyncResult) error {
return testDB.ReleaseGitHubAlpineSyncLease(ctx(), n, "r1", res)
@@ -276,3 +280,210 @@ func TestGitHubSyncStateDeletedWithRemote(t *testing.T) {
})
}
}
type syncTable struct {
table string
pt models.PackageType
claim func(name, owner string, freshness, interval, phase time.Duration) (bool, error)
}
func syncTables() []syncTable {
wrap := func(fn func(context.Context, string, string, time.Duration, time.Duration, time.Duration, time.Duration) (bool, string, error)) func(string, string, time.Duration, time.Duration, time.Duration) (bool, error) {
return func(name, owner string, freshness, interval, phase time.Duration) (bool, error) {
ok, _, err := fn(ctx(), name, owner, freshness, 15*time.Minute, interval, phase)
return ok, err
}
}
return []syncTable{
{"github_rpm_sync_state", models.PackageGitHubRPM, wrap(testDB.ClaimGitHubSyncLease)},
{"github_deb_sync_state", models.PackageGitHubDeb, wrap(testDB.ClaimGitHubDebSyncLease)},
{"github_alpine_sync_state", models.PackageGitHubAlpine, wrap(testDB.ClaimGitHubAlpineSyncLease)},
}
}
var syncSeq atomic.Int64
// seedSynced creates a remote of tc's type created createdAgo before the DB
// clock and, when syncedAgo >= 0, a released lease last synced syncedAgo before it.
func seedSynced(t *testing.T, tc syncTable, createdAgo, syncedAgo time.Duration) string {
t.Helper()
name := fmt.Sprintf("sync-%s-%d", tc.table, syncSeq.Add(1))
if err := testDB.CreateRemote(ctx(), &models.Remote{
Name: name, PackageType: tc.pt, RepoType: models.RepoTypeRemote,
BaseURL: "https://api.github.com/repos/acme/tools", ReleasesRemote: "github",
}); err != nil {
t.Fatalf("create remote: %v", err)
}
if _, err := testDB.Pool.Exec(ctx(), `UPDATE remotes SET created_at = now() - make_interval(secs => $2) WHERE name = $1`, name, createdAgo.Seconds()); err != nil {
t.Fatalf("seed created_at: %v", err)
}
if syncedAgo >= 0 {
if _, err := testDB.Pool.Exec(ctx(), `INSERT INTO `+tc.table+` (remote_name, last_synced_at) VALUES ($1, now() - make_interval(secs => $2))`, name, syncedAgo.Seconds()); err != nil {
t.Fatalf("seed sync state: %v", err)
}
}
return name
}
func mustClaim(t *testing.T, tc syncTable, name, owner string, freshness, interval, phase time.Duration) bool {
t.Helper()
ok, err := tc.claim(name, owner, freshness, interval, phase)
if err != nil {
t.Fatalf("claim: %v", err)
}
return ok
}
const neverSynced = -1
// With no interval the claim keeps the freshness-since-last-sync predicate and
// a new remote is due at once.
func TestSyncLeaseUnsetIntervalFreshness(t *testing.T) {
requireDB(t)
const ttl = time.Hour
for _, tc := range syncTables() {
t.Run(tc.table, func(t *testing.T) {
for _, c := range []struct {
ago time.Duration
freshness time.Duration
due bool
}{
{neverSynced, ttl, true},
{ttl + time.Minute, ttl, true},
{ttl - time.Minute, ttl, false},
{time.Second, ttl, false},
{time.Second, 0, true},
} {
name := seedSynced(t, tc, time.Second, c.ago)
if got := mustClaim(t, tc, name, "r1", c.freshness, 0, 0); got != c.due {
t.Errorf("synced %v ago, freshness %v: claimed=%v, want %v", c.ago, c.freshness, got, c.due)
}
}
})
}
}
// With an interval the slot comes from the DB clock: a remote is due only when
// created and last synced before the current slot, regardless of freshness,
// and still one replica at a time.
func TestSyncLeaseSlot(t *testing.T) {
requireDB(t)
var epoch float64
if err := testDB.Pool.QueryRow(ctx(), `SELECT extract(epoch FROM now())::float8`).Scan(&epoch); err != nil {
t.Fatalf("db now: %v", err)
}
const interval = time.Hour
// Pick the phase that puts the current slot's start 30m before the DB now.
phase := time.Duration(math.Mod(epoch-1800, interval.Seconds()) * float64(time.Second))
const day = 24 * time.Hour
for _, tc := range syncTables() {
t.Run(tc.table, func(t *testing.T) {
for _, c := range []struct {
desc string
createdAgo, ago time.Duration
freshness time.Duration
due bool
}{
{"created inside the slot", 10 * time.Second, neverSynced, 0, false},
{"created before the slot", 35 * time.Minute, neverSynced, 0, true},
{"synced inside the slot", day, 25 * time.Minute, 0, false},
{"synced before the slot", day, 35 * time.Minute, 10 * 365 * day, true},
} {
name := seedSynced(t, tc, c.createdAgo, c.ago)
if got := mustClaim(t, tc, name, "r1", c.freshness, interval, phase); got != c.due {
t.Errorf("%s: claimed=%v, want %v", c.desc, got, c.due)
}
if c.due && mustClaim(t, tc, name, "r2", 0, interval, phase) {
t.Errorf("%s: second replica claimed a held lease", c.desc)
}
}
})
}
}
// slotPhase returns the phase that puts the current interval slot's start
// startAgo before the DB clock.
func slotPhase(t *testing.T, interval, startAgo time.Duration) time.Duration {
t.Helper()
var epoch float64
if err := testDB.Pool.QueryRow(ctx(), `SELECT extract(epoch FROM now())::float8`).Scan(&epoch); err != nil {
t.Fatalf("db now: %v", err)
}
return time.Duration(math.Mod(epoch-startAgo.Seconds(), interval.Seconds()) * float64(time.Second))
}
func setSyncedAgo(t *testing.T, tc syncTable, name string, ago time.Duration) {
t.Helper()
if _, err := testDB.Pool.Exec(ctx(), `UPDATE `+tc.table+` SET last_synced_at = now() - make_interval(secs => $2), sync_lease_owner = '', sync_lease_expires = NULL WHERE remote_name = $1`, name, ago.Seconds()); err != nil {
t.Fatalf("set last_synced_at: %v", err)
}
}
// Toggling sync_interval on a remote with sync state neither re-syncs at once
// nor leaves it waiting longer than one interval (or freshness) since its last
// sync. Elapsed time is simulated by moving last_synced_at and the slot start
// back together, relative to the DB clock.
func TestSyncLeaseIntervalChange(t *testing.T) {
requireDB(t)
const (
interval = time.Hour
freshness = time.Hour
day = 24 * time.Hour
)
for _, tc := range syncTables() {
t.Run(tc.table, func(t *testing.T) {
t.Run("0 to N", func(t *testing.T) {
name := seedSynced(t, tc, day, 25*time.Minute)
if mustClaim(t, tc, name, "r1", freshness, 0, 0) {
t.Fatal("claimed inside freshness before the change")
}
if mustClaim(t, tc, name, "r1", freshness, interval, slotPhase(t, interval, 30*time.Minute)) {
t.Fatal("synced inside the current slot, yet claimed right after the change")
}
// 30m later the next slot opens; the last sync is 55m old.
setSyncedAgo(t, tc, name, 55*time.Minute)
if !mustClaim(t, tc, name, "r1", freshness, interval, slotPhase(t, interval, time.Second)) {
t.Fatal("not due at the first slot after the last sync")
}
})
t.Run("N to 0", func(t *testing.T) {
name := seedSynced(t, tc, day, 10*time.Minute)
if mustClaim(t, tc, name, "r1", freshness, interval, slotPhase(t, interval, 30*time.Minute)) {
t.Fatal("claimed inside the slot before the change")
}
if mustClaim(t, tc, name, "r1", freshness, 0, 0) {
t.Fatal("synced inside freshness, yet claimed right after the change")
}
setSyncedAgo(t, tc, name, freshness+time.Second)
if !mustClaim(t, tc, name, "r1", freshness, 0, 0) {
t.Fatal("not due once freshness elapsed after the change")
}
})
})
}
}
// The created_at gate only holds back the first insert of a remote created
// inside the current slot; once a state row exists the row's own sync and
// retry state decide.
func TestSyncLeaseCreatedGateInsertOnly(t *testing.T) {
requireDB(t)
const interval = time.Hour
phase := slotPhase(t, interval, 30*time.Minute)
for _, tc := range syncTables() {
t.Run(tc.table, func(t *testing.T) {
fresh := seedSynced(t, tc, 10*time.Second, neverSynced)
if mustClaim(t, tc, fresh, "r1", 0, interval, phase) {
t.Fatal("remote created inside the slot claimed with no state row")
}
name := seedSynced(t, tc, 10*time.Second, neverSynced)
if _, err := testDB.Pool.Exec(ctx(), `INSERT INTO `+tc.table+` (remote_name, sync_failures, next_retry_at) VALUES ($1, 1, now() - interval '1 second')`, name); err != nil {
t.Fatalf("seed failed prime: %v", err)
}
if !mustClaim(t, tc, name, "r1", 0, interval, phase) {
t.Fatal("created_at gate blocked a remote with a state row whose retry is due")
}
})
}
}
+22 -9
View File
@@ -3,16 +3,19 @@ package database
import (
"context"
"github.com/jackc/pgx/v5"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
const remoteCols = `name, package_type, repo_type, base_url, mirrorlist, mirror_strategy, description, username, password,
immutable_ttl, mutable_ttl, check_mutable,
immutable_ttl, mutable_ttl, check_mutable, sync_interval,
patterns, blocklist, mutable_patterns, immutable_patterns,
ban_tags_enabled, ban_tags,
quarantine_enabled, quarantine_days, stale_on_error,
releases_remote, managed_by,
upstream_dial_timeout, upstream_tls_timeout, upstream_response_header_timeout,
serve_stale, cache_repodata,
created_at, updated_at`
// normalizeMirrorStrategy maps an empty strategy to the round_robin default so
@@ -27,12 +30,13 @@ func normalizeMirrorStrategy(s string) string {
func scanRemote(scanner interface{ Scan(...any) error }, r *models.Remote) error {
return scanner.Scan(
&r.Name, &r.PackageType, &r.RepoType, &r.BaseURL, &r.Mirrorlist, &r.MirrorStrategy, &r.Description, &r.Username, &r.Password,
&r.ImmutableTTL, &r.MutableTTL, &r.CheckMutable,
&r.ImmutableTTL, &r.MutableTTL, &r.CheckMutable, &r.SyncInterval,
&r.Patterns, &r.Blocklist, &r.MutablePatterns, &r.ImmutablePatterns,
&r.BanTagsEnabled, &r.BanTags,
&r.QuarantineEnabled, &r.QuarantineDays, &r.StaleOnError,
&r.ReleasesRemote, &r.ManagedBy,
&r.UpstreamDialTimeout, &r.UpstreamTLSTimeout, &r.UpstreamResponseHeaderTimeout,
&r.ServeStale, &r.CacheRepodata,
&r.CreatedAt, &r.UpdatedAt,
)
}
@@ -74,8 +78,8 @@ func (db *DB) CreateRemote(ctx context.Context, r *models.Remote) error {
quarantine_enabled, quarantine_days, stale_on_error,
releases_remote, managed_by,
upstream_dial_timeout, upstream_tls_timeout, upstream_response_header_timeout,
mirror_strategy
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26)
mirror_strategy, sync_interval, serve_stale, cache_repodata
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29)
`,
r.Name, r.PackageType, r.RepoType, r.BaseURL, r.Mirrorlist, r.Description, r.Username, r.Password,
r.ImmutableTTL, r.MutableTTL, r.CheckMutable,
@@ -84,7 +88,7 @@ func (db *DB) CreateRemote(ctx context.Context, r *models.Remote) error {
r.QuarantineEnabled, r.QuarantineDays, r.StaleOnError,
r.ReleasesRemote, r.ManagedBy,
r.UpstreamDialTimeout, r.UpstreamTLSTimeout, r.UpstreamResponseHeaderTimeout,
normalizeMirrorStrategy(r.MirrorStrategy),
normalizeMirrorStrategy(r.MirrorStrategy), r.SyncInterval, r.ServeStale, r.CacheRepodata,
)
return err
}
@@ -99,7 +103,7 @@ func (db *DB) UpdateRemote(ctx context.Context, r *models.Remote) error {
quarantine_enabled=$17, quarantine_days=$18, stale_on_error=$19,
releases_remote=$20, managed_by=$21,
upstream_dial_timeout=$22, upstream_tls_timeout=$23, upstream_response_header_timeout=$24,
mirror_strategy=$26,
mirror_strategy=$26, sync_interval=$27, serve_stale=$28, cache_repodata=$29,
updated_at=NOW()
WHERE name=$1
`,
@@ -111,12 +115,21 @@ func (db *DB) UpdateRemote(ctx context.Context, r *models.Remote) error {
r.ReleasesRemote, r.ManagedBy,
r.UpstreamDialTimeout, r.UpstreamTLSTimeout, r.UpstreamResponseHeaderTimeout,
r.Mirrorlist,
normalizeMirrorStrategy(r.MirrorStrategy),
normalizeMirrorStrategy(r.MirrorStrategy), r.SyncInterval, r.ServeStale, r.CacheRepodata,
)
return err
}
// DeleteRemote drops a remote with its local files and package metadata, so a
// repo recreated under the same name starts empty.
func (db *DB) DeleteRemote(ctx context.Context, name string) error {
_, err := db.Pool.Exec(ctx, `DELETE FROM remotes WHERE name = $1`, name)
return err
return pgx.BeginFunc(ctx, db.Pool, func(tx pgx.Tx) error {
for _, table := range []string{"local_files", "rpm_metadata", "deb_metadata", "alpine_metadata", "puppet_metadata", "helm_metadata"} {
if _, err := tx.Exec(ctx, `DELETE FROM `+table+` WHERE repo_name = $1`, name); err != nil {
return err
}
}
_, err := tx.Exec(ctx, `DELETE FROM remotes WHERE name = $1`, name)
return err
})
}
+8
View File
@@ -40,6 +40,14 @@ func (db *DB) DeleteRPMMetadata(ctx context.Context, repoName, filePath string)
return err
}
// RPMMetadataFingerprint is a cheap change detector for a repo's rows: they are
// only ever inserted or deleted, so (count, max id) moves whenever they change.
// For github_rpm remotes it relies on the sync lease serialising scans.
func (db *DB) RPMMetadataFingerprint(ctx context.Context, repoName string) (count, maxID int64, err error) {
err = db.Pool.QueryRow(ctx, `SELECT count(*), coalesce(max(id), 0) FROM rpm_metadata WHERE repo_name = $1`, repoName).Scan(&count, &maxID)
return count, maxID, err
}
type RPMMetadataRow struct {
RepoName string
FilePath string
+9 -9
View File
@@ -8,12 +8,12 @@ import (
func (db *DB) GetVirtual(ctx context.Context, name string) (*models.Virtual, error) {
row := db.Pool.QueryRow(ctx, `
SELECT name, package_type, description, members, managed_by, created_at, updated_at
SELECT name, package_type, description, members, managed_by, parallel_fetch, created_at, updated_at
FROM virtuals WHERE name = $1
`, name)
var v models.Virtual
err := row.Scan(&v.Name, &v.PackageType, &v.Description, &v.Members, &v.ManagedBy, &v.CreatedAt, &v.UpdatedAt)
err := row.Scan(&v.Name, &v.PackageType, &v.Description, &v.Members, &v.ManagedBy, &v.ParallelFetch, &v.CreatedAt, &v.UpdatedAt)
if err != nil {
return nil, err
}
@@ -22,7 +22,7 @@ func (db *DB) GetVirtual(ctx context.Context, name string) (*models.Virtual, err
func (db *DB) ListVirtuals(ctx context.Context) ([]models.Virtual, error) {
rows, err := db.Pool.Query(ctx, `
SELECT name, package_type, description, members, managed_by, created_at, updated_at
SELECT name, package_type, description, members, managed_by, parallel_fetch, created_at, updated_at
FROM virtuals ORDER BY name
`)
if err != nil {
@@ -33,7 +33,7 @@ func (db *DB) ListVirtuals(ctx context.Context) ([]models.Virtual, error) {
var virtuals []models.Virtual
for rows.Next() {
var v models.Virtual
if err := rows.Scan(&v.Name, &v.PackageType, &v.Description, &v.Members, &v.ManagedBy, &v.CreatedAt, &v.UpdatedAt); err != nil {
if err := rows.Scan(&v.Name, &v.PackageType, &v.Description, &v.Members, &v.ManagedBy, &v.ParallelFetch, &v.CreatedAt, &v.UpdatedAt); err != nil {
return nil, err
}
virtuals = append(virtuals, v)
@@ -43,18 +43,18 @@ func (db *DB) ListVirtuals(ctx context.Context) ([]models.Virtual, error) {
func (db *DB) CreateVirtual(ctx context.Context, v *models.Virtual) error {
_, err := db.Pool.Exec(ctx, `
INSERT INTO virtuals (name, package_type, description, members, managed_by)
VALUES ($1, $2, $3, $4, $5)
`, v.Name, v.PackageType, v.Description, v.Members, v.ManagedBy)
INSERT INTO virtuals (name, package_type, description, members, managed_by, parallel_fetch)
VALUES ($1, $2, $3, $4, $5, $6)
`, v.Name, v.PackageType, v.Description, v.Members, v.ManagedBy, v.ParallelFetch)
return err
}
func (db *DB) UpdateVirtual(ctx context.Context, v *models.Virtual) error {
_, err := db.Pool.Exec(ctx, `
UPDATE virtuals SET
package_type=$2, description=$3, members=$4, managed_by=$5, updated_at=NOW()
package_type=$2, description=$3, members=$4, managed_by=$5, parallel_fetch=$6, updated_at=NOW()
WHERE name=$1
`, v.Name, v.PackageType, v.Description, v.Members, v.ManagedBy)
`, v.Name, v.PackageType, v.Description, v.Members, v.ManagedBy, v.ParallelFetch)
return err
}
+3
View File
@@ -0,0 +1,3 @@
package all
import _ "git.unkin.net/unkin/artifactapi/internal/provider/ansible"
+3
View File
@@ -0,0 +1,3 @@
package all
import _ "git.unkin.net/unkin/artifactapi/internal/provider/vcs"
+11 -8
View File
@@ -27,7 +27,7 @@ const (
type SyncStore interface {
provider.RemoteMetadataStore
ListGitHubAlpineRemotes(ctx context.Context) ([]models.Remote, error)
ClaimGitHubAlpineSyncLease(ctx context.Context, remoteName, owner string, freshness, lease time.Duration) (claimed bool, etag string, err error)
ClaimGitHubAlpineSyncLease(ctx context.Context, remoteName, owner string, freshness, lease, interval, phase time.Duration) (claimed bool, etag string, err error)
ReleaseGitHubAlpineSyncLease(ctx context.Context, remoteName, owner string, res provider.SyncResult) error
}
@@ -129,7 +129,8 @@ func (s *Syncer) Run(ctx context.Context) {
}
// schedule enqueues a periodic check for every github_alpine remote. The DB lease
// enforces the per-remote mutable_ttl cadence and cross-replica coordination.
// enforces the per-remote sync_interval (else mutable_ttl) cadence and
// cross-replica coordination.
func (s *Syncer) schedule(ctx context.Context) {
remotes, err := s.store.ListGitHubAlpineRemotes(ctx)
if err != nil {
@@ -142,8 +143,10 @@ func (s *Syncer) schedule(ctx context.Context) {
}
// EnqueuePrime queues an immediate background prime for a freshly created remote.
// A sync_interval remote skips it and syncs in its first slot, so remotes
// created together do not burst.
func (s *Syncer) EnqueuePrime(remote models.Remote) {
if s == nil {
if s == nil || remote.SyncInterval > 0 {
return
}
s.enqueue(remote, true)
@@ -189,16 +192,16 @@ func (s *Syncer) process(ctx context.Context, job syncJob) {
s.mu.Unlock()
}()
ttl := time.Duration(job.remote.MutableTTL) * time.Second
if ttl <= 0 {
ttl = defaultSyncFreshness
}
ttl := job.remote.SyncPeriod(defaultSyncFreshness)
freshness := ttl
var interval, phase time.Duration
if job.prime {
freshness = 0
} else {
interval, phase = job.remote.SyncSchedule()
}
claimed, etag, err := s.store.ClaimGitHubAlpineSyncLease(ctx, job.remote.Name, s.owner, freshness, syncLeaseDuration)
claimed, etag, err := s.store.ClaimGitHubAlpineSyncLease(ctx, job.remote.Name, s.owner, freshness, syncLeaseDuration, interval, phase)
if err != nil {
slog.Error("github_alpine syncer: claim lease", "remote", job.remote.Name, "error", err)
return
+42 -2
View File
@@ -28,6 +28,10 @@ type fakeSyncStore struct {
lastSynced map[string]time.Time
etags map[string]string
retryAt map[string]time.Time
freshness time.Duration
interval time.Duration
phase time.Duration
attempts int
}
func newFakeSyncStore() *fakeSyncStore {
@@ -47,9 +51,11 @@ func (f *fakeSyncStore) ListGitHubAlpineRemotes(_ context.Context) ([]models.Rem
return append([]models.Remote(nil), f.remotes...), nil
}
func (f *fakeSyncStore) ClaimGitHubAlpineSyncLease(_ context.Context, name, owner string, freshness, lease time.Duration) (bool, string, error) {
func (f *fakeSyncStore) ClaimGitHubAlpineSyncLease(_ context.Context, name, owner string, freshness, lease, interval, phase time.Duration) (bool, string, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.freshness, f.interval, f.phase = freshness, interval, phase
f.attempts++
now := time.Now()
ls, hasLS := f.lastSynced[name]
exp, hasExp := f.leaseExp[name]
@@ -223,7 +229,7 @@ func TestSyncerLeasePreventsSecondReplica(t *testing.T) {
s := newSyncer(store, p, testSyncConfig())
remote := fx.remote()
claimed, _, err := store.ClaimGitHubAlpineSyncLease(context.Background(), remote.Name, "replica-1", time.Duration(remote.MutableTTL)*time.Second, syncLeaseDuration)
claimed, _, err := store.ClaimGitHubAlpineSyncLease(context.Background(), remote.Name, "replica-1", time.Duration(remote.MutableTTL)*time.Second, syncLeaseDuration, 0, 0)
if err != nil || !claimed {
t.Fatalf("replica-1 claim: claimed=%v err=%v", claimed, err)
}
@@ -363,3 +369,37 @@ func TestSyncerFailedAssetRetry(t *testing.T) {
})
}
}
// A periodic check claims with sync_interval as its freshness and passes the
// remote's slot schedule; unset, it falls back to mutable_ttl with no schedule.
func TestSyncerHonoursSyncInterval(t *testing.T) {
for _, interval := range []int{0, 7200} {
store := newFakeSyncStore()
s := newSyncer(store, newTestProvider(), testSyncConfig())
store.lastSynced["sched"] = time.Now()
remote := models.Remote{Name: "sched", MutableTTL: 90, SyncInterval: interval}
wantInterval, wantPhase := remote.SyncSchedule()
s.process(context.Background(), syncJob{remote: remote})
if want := remote.SyncPeriod(0); store.freshness != want || store.interval != wantInterval || store.phase != wantPhase {
t.Errorf("sync_interval=%d: claim freshness/interval/phase = %v/%v/%v, want %v/%v/%v",
interval, store.freshness, store.interval, store.phase, want, wantInterval, wantPhase)
}
}
}
// Create-time primes are skipped for a scheduled remote so remotes created
// together spread out; a cold-cache prime still claims with no schedule.
func TestSyncerScheduledPrime(t *testing.T) {
store := newFakeSyncStore()
s := newSyncer(store, newTestProvider(), testSyncConfig())
remote := models.Remote{Name: "fresh", SyncInterval: 3600}
s.EnqueuePrime(remote)
if len(s.jobs) != 0 {
t.Fatal("create-time prime enqueued for a sync_interval remote")
}
s.process(context.Background(), syncJob{remote: remote, prime: true})
if store.attempts != 1 || store.freshness != 0 || store.interval != 0 {
t.Fatalf("cold-cache prime: attempts=%d freshness=%v interval=%v, want 1/0/0", store.attempts, store.freshness, store.interval)
}
}
+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)
+11 -8
View File
@@ -27,7 +27,7 @@ const (
type SyncStore interface {
provider.RemoteMetadataStore
ListGitHubDebRemotes(ctx context.Context) ([]models.Remote, error)
ClaimGitHubDebSyncLease(ctx context.Context, remoteName, owner string, freshness, lease time.Duration) (claimed bool, etag string, err error)
ClaimGitHubDebSyncLease(ctx context.Context, remoteName, owner string, freshness, lease, interval, phase time.Duration) (claimed bool, etag string, err error)
ReleaseGitHubDebSyncLease(ctx context.Context, remoteName, owner string, res provider.SyncResult) error
}
@@ -129,7 +129,8 @@ func (s *Syncer) Run(ctx context.Context) {
}
// schedule enqueues a periodic check for every github_deb remote. The DB lease
// enforces the per-remote mutable_ttl cadence and cross-replica coordination.
// enforces the per-remote sync_interval (else mutable_ttl) cadence and
// cross-replica coordination.
func (s *Syncer) schedule(ctx context.Context) {
remotes, err := s.store.ListGitHubDebRemotes(ctx)
if err != nil {
@@ -142,8 +143,10 @@ func (s *Syncer) schedule(ctx context.Context) {
}
// EnqueuePrime queues an immediate background prime for a freshly created remote.
// A sync_interval remote skips it and syncs in its first slot, so remotes
// created together do not burst.
func (s *Syncer) EnqueuePrime(remote models.Remote) {
if s == nil {
if s == nil || remote.SyncInterval > 0 {
return
}
s.enqueue(remote, true)
@@ -189,16 +192,16 @@ func (s *Syncer) process(ctx context.Context, job syncJob) {
s.mu.Unlock()
}()
ttl := time.Duration(job.remote.MutableTTL) * time.Second
if ttl <= 0 {
ttl = defaultSyncFreshness
}
ttl := job.remote.SyncPeriod(defaultSyncFreshness)
freshness := ttl
var interval, phase time.Duration
if job.prime {
freshness = 0
} else {
interval, phase = job.remote.SyncSchedule()
}
claimed, etag, err := s.store.ClaimGitHubDebSyncLease(ctx, job.remote.Name, s.owner, freshness, syncLeaseDuration)
claimed, etag, err := s.store.ClaimGitHubDebSyncLease(ctx, job.remote.Name, s.owner, freshness, syncLeaseDuration, interval, phase)
if err != nil {
slog.Error("github_deb syncer: claim lease", "remote", job.remote.Name, "error", err)
return
+42 -2
View File
@@ -28,6 +28,10 @@ type fakeSyncStore struct {
lastSynced map[string]time.Time
etags map[string]string
retryAt map[string]time.Time
freshness time.Duration
interval time.Duration
phase time.Duration
attempts int
}
func newFakeSyncStore() *fakeSyncStore {
@@ -47,9 +51,11 @@ func (f *fakeSyncStore) ListGitHubDebRemotes(_ context.Context) ([]models.Remote
return append([]models.Remote(nil), f.remotes...), nil
}
func (f *fakeSyncStore) ClaimGitHubDebSyncLease(_ context.Context, name, owner string, freshness, lease time.Duration) (bool, string, error) {
func (f *fakeSyncStore) ClaimGitHubDebSyncLease(_ context.Context, name, owner string, freshness, lease, interval, phase time.Duration) (bool, string, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.freshness, f.interval, f.phase = freshness, interval, phase
f.attempts++
now := time.Now()
ls, hasLS := f.lastSynced[name]
exp, hasExp := f.leaseExp[name]
@@ -223,7 +229,7 @@ func TestSyncerLeasePreventsSecondReplica(t *testing.T) {
s := newSyncer(store, p, testSyncConfig())
remote := fx.remote()
claimed, _, err := store.ClaimGitHubDebSyncLease(context.Background(), remote.Name, "replica-1", time.Duration(remote.MutableTTL)*time.Second, syncLeaseDuration)
claimed, _, err := store.ClaimGitHubDebSyncLease(context.Background(), remote.Name, "replica-1", time.Duration(remote.MutableTTL)*time.Second, syncLeaseDuration, 0, 0)
if err != nil || !claimed {
t.Fatalf("replica-1 claim: claimed=%v err=%v", claimed, err)
}
@@ -363,3 +369,37 @@ func TestSyncerFailedAssetRetry(t *testing.T) {
})
}
}
// A periodic check claims with sync_interval as its freshness and passes the
// remote's slot schedule; unset, it falls back to mutable_ttl with no schedule.
func TestSyncerHonoursSyncInterval(t *testing.T) {
for _, interval := range []int{0, 7200} {
store := newFakeSyncStore()
s := newSyncer(store, newTestProvider(), testSyncConfig())
store.lastSynced["sched"] = time.Now()
remote := models.Remote{Name: "sched", MutableTTL: 90, SyncInterval: interval}
wantInterval, wantPhase := remote.SyncSchedule()
s.process(context.Background(), syncJob{remote: remote})
if want := remote.SyncPeriod(0); store.freshness != want || store.interval != wantInterval || store.phase != wantPhase {
t.Errorf("sync_interval=%d: claim freshness/interval/phase = %v/%v/%v, want %v/%v/%v",
interval, store.freshness, store.interval, store.phase, want, wantInterval, wantPhase)
}
}
}
// Create-time primes are skipped for a scheduled remote so remotes created
// together spread out; a cold-cache prime still claims with no schedule.
func TestSyncerScheduledPrime(t *testing.T) {
store := newFakeSyncStore()
s := newSyncer(store, newTestProvider(), testSyncConfig())
remote := models.Remote{Name: "fresh", SyncInterval: 3600}
s.EnqueuePrime(remote)
if len(s.jobs) != 0 {
t.Fatal("create-time prime enqueued for a sync_interval remote")
}
s.process(context.Background(), syncJob{remote: remote, prime: true})
if store.attempts != 1 || store.freshness != 0 || store.interval != 0 {
t.Fatalf("cold-cache prime: attempts=%d freshness=%v interval=%v, want 1/0/0", store.attempts, store.freshness, store.interval)
}
}
+3 -3
View File
@@ -71,7 +71,7 @@ func (p *Provider) AuthHeaders(ctx context.Context, remote models.Remote) (http.
h.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(remote.Username+":"+remote.Password)))
return h, nil
}
if isGitHubHost(remote.BaseURL) {
if IsGitHubHost(remote.BaseURL) {
if c := githubauth.Server(); c != nil {
tok, err := c.Token(ctx)
if err != nil {
@@ -85,11 +85,11 @@ func (p *Provider) AuthHeaders(ctx context.Context, remote models.Remote) (http.
return h, nil
}
// isGitHubHost reports whether rawURL targets a GitHub API/download host that
// IsGitHubHost reports whether rawURL targets a GitHub API/download host that
// accepts the server credential. objects.githubusercontent.com is deliberately
// excluded: release-asset downloads 302-redirect there with a pre-signed URL
// that must not carry an Authorization header.
func isGitHubHost(rawURL string) bool {
func IsGitHubHost(rawURL string) bool {
u, err := url.Parse(rawURL)
if err != nil {
return false
+223
View File
@@ -0,0 +1,223 @@
package goproxy
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"slices"
"strings"
"golang.org/x/mod/modfile"
"golang.org/x/mod/module"
"golang.org/x/mod/semver"
modzip "golang.org/x/mod/zip"
"git.unkin.net/unkin/artifactapi/internal/provider"
)
type versionFile struct {
module string
version string
ext string
}
// parseVersionFile splits "<escaped module>/@v/<version>.<ext>" into its parts.
func parseVersionFile(path string) (versionFile, error) {
escMod, file, ok := strings.Cut(strings.TrimPrefix(path, "/"), "/@v/")
if !ok || escMod == "" || strings.Contains(file, "/") {
return versionFile{}, fmt.Errorf("path %q must be <module>/@v/<version>.{zip,mod,info}", path)
}
dot := strings.LastIndex(file, ".")
if dot <= 0 {
return versionFile{}, fmt.Errorf("path %q must end in .zip, .mod or .info", path)
}
ext := file[dot+1:]
if ext != "zip" && ext != "mod" && ext != "info" {
return versionFile{}, fmt.Errorf("path %q must end in .zip, .mod or .info", path)
}
mod, err := module.UnescapePath(escMod)
if err != nil {
return versionFile{}, fmt.Errorf("module path must be case-encoded (uppercase as !lower): %w", err)
}
ver, err := module.UnescapeVersion(file[:dot])
if err != nil {
return versionFile{}, err
}
if err := module.Check(mod, ver); err != nil {
return versionFile{}, err
}
if semver.Canonical(ver) != ver {
return versionFile{}, fmt.Errorf("version %q is not canonical semver", ver)
}
return versionFile{module: mod, version: ver, ext: ext}, nil
}
func (v versionFile) storagePath() string {
escMod, _ := module.EscapePath(v.module)
escVer, _ := module.EscapeVersion(v.version)
return escMod + "/@v/" + escVer + "." + v.ext
}
func (p *Provider) ValidateUpload(filePath string) (storagePath, contentType string, err error) {
vf, err := parseVersionFile(filePath)
if err != nil {
return "", "", err
}
sp := vf.storagePath()
return sp, p.ContentType(sp), nil
}
func (p *Provider) ValidateContent(storagePath, localFile string) error {
vf, err := parseVersionFile(storagePath)
if err != nil {
return err
}
switch vf.ext {
case "zip":
if _, err := modzip.CheckZip(module.Version{Path: vf.module, Version: vf.version}, localFile); err != nil {
return fmt.Errorf("invalid module zip (files must sit under %s@%s/): %w", vf.module, vf.version, err)
}
case "mod":
data, err := os.ReadFile(localFile)
if err != nil {
return err
}
mf, err := modfile.ParseLax("go.mod", data, nil)
if err != nil {
return fmt.Errorf("invalid go.mod: %w", err)
}
if mf.Module == nil || mf.Module.Mod.Path != vf.module {
return fmt.Errorf("go.mod must declare module %s", vf.module)
}
case "info":
data, err := os.ReadFile(localFile)
if err != nil {
return err
}
var info struct{ Version string }
if err := json.Unmarshal(data, &info); err != nil {
return fmt.Errorf("invalid .info JSON: %w", err)
}
if info.Version != vf.version {
return fmt.Errorf(".info Version %q does not match %s", info.Version, vf.version)
}
}
return nil
}
func (p *Provider) UploadResponse(storagePath, contentHash string, sizeBytes int64) map[string]any {
vf, _ := parseVersionFile(storagePath)
return map[string]any{
"module": vf.module,
"version": vf.version,
"path": storagePath,
"content_hash": contentHash,
"size_bytes": sizeBytes,
}
}
// versions returns the canonical versions of escMod that have a .mod uploaded, sorted ascending.
func versions(ctx context.Context, files provider.FileStore, repoName, escMod string) (versions []string, infos map[string]bool, err error) {
entries, err := files.ListFilesByPrefix(ctx, repoName, escMod+"/@v/")
if err != nil {
return nil, nil, err
}
infos = map[string]bool{}
for _, e := range entries {
vf, err := parseVersionFile(e.FilePath)
if err != nil || vf.storagePath() != e.FilePath {
continue
}
switch vf.ext {
case "mod":
versions = append(versions, vf.version)
case "info":
infos[vf.version] = true
}
}
semver.Sort(versions)
return versions, infos, nil
}
// Latest is the highest release in sorted vs, else the highest prerelease.
func Latest(vs []string) string {
for i := len(vs) - 1; i >= 0; i-- {
if semver.Prerelease(vs[i]) == "" {
return vs[i]
}
}
if len(vs) == 0 {
return ""
}
return vs[len(vs)-1]
}
func infoJSON(version string) []byte {
b, _ := json.Marshal(map[string]string{"Version": version})
return b
}
func (p *Provider) ServeLocalIndex(w http.ResponseWriter, r *http.Request, files provider.FileStore, repoName, path string) bool {
path = strings.TrimPrefix(path, "/")
switch {
case strings.HasSuffix(path, "/@v/list"), strings.HasSuffix(path, "/@latest"):
case strings.HasSuffix(path, ".info"):
vf, err := parseVersionFile(path)
if err != nil {
return false
}
escMod, _ := module.EscapePath(vf.module)
vs, infos, err := versions(r.Context(), files, repoName, escMod)
if err != nil || infos[vf.version] || !slices.Contains(vs, vf.version) {
return false
}
default:
return false
}
body, err := p.GenerateLocalIndex(r.Context(), files, repoName, path)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return true
}
w.Header().Set("Content-Type", p.ContentType(path))
w.WriteHeader(http.StatusOK)
_, _ = w.Write(body)
return true
}
func (p *Provider) GenerateLocalIndex(ctx context.Context, files provider.FileStore, repoName, path string) ([]byte, error) {
path = strings.TrimPrefix(path, "/")
if escMod, ok := strings.CutSuffix(path, "/@v/list"); ok {
vs, _, err := versions(ctx, files, repoName, escMod)
if err != nil {
return nil, err
}
var b strings.Builder
for _, v := range vs {
b.WriteString(v + "\n")
}
return []byte(b.String()), nil
}
if escMod, ok := strings.CutSuffix(path, "/@latest"); ok {
vs, _, err := versions(ctx, files, repoName, escMod)
if err != nil {
return nil, err
}
v := Latest(vs)
if v == "" {
return nil, fmt.Errorf("no versions of %s", escMod)
}
return infoJSON(v), nil
}
if strings.HasSuffix(path, ".info") {
vf, err := parseVersionFile(path)
if err != nil {
return nil, err
}
return infoJSON(vf.version), nil
}
return nil, fmt.Errorf("unsupported index path: %q", path)
}
+198
View File
@@ -0,0 +1,198 @@
package goproxy
import (
"archive/zip"
"context"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"golang.org/x/mod/module"
modzip "golang.org/x/mod/zip"
"git.unkin.net/unkin/artifactapi/internal/provider"
)
type fakeFileStore struct{ paths []string }
func (f *fakeFileStore) ListPackages(context.Context, string) ([]string, error) { return nil, nil }
func (f *fakeFileStore) ListFilesByPrefix(_ context.Context, _, prefix string) ([]provider.FileEntry, error) {
var out []provider.FileEntry
for _, p := range f.paths {
if strings.HasPrefix(p, prefix) {
out = append(out, provider.FileEntry{FilePath: p, ContentHash: "sha256:x"})
}
}
return out, nil
}
func TestValidateUpload(t *testing.T) {
p := &Provider{}
cases := []struct {
in, want string
ok bool
}{
{"example.unkin.net/hello/@v/v0.1.0.zip", "example.unkin.net/hello/@v/v0.1.0.zip", true},
{"/example.unkin.net/hello/@v/v0.1.0.mod", "example.unkin.net/hello/@v/v0.1.0.mod", true},
{"github.com/!burnt!sushi/toml/@v/v1.2.0.info", "github.com/!burnt!sushi/toml/@v/v1.2.0.info", true},
{"example.unkin.net/hello/v2/@v/v2.0.0-rc.1.zip", "example.unkin.net/hello/v2/@v/v2.0.0-rc.1.zip", true},
{"github.com/BurntSushi/toml/@v/v1.2.0.zip", "", false},
{"example.unkin.net/hello/@v/v0.1.zip", "", false},
{"example.unkin.net/hello/@v/v2.0.0.zip", "", false},
{"example.unkin.net/hello/@v/v0.1.0.tar.gz", "", false},
{"example.unkin.net/hello/v0.1.0.zip", "", false},
{"example.unkin.net/hello/@v/list", "", false},
}
for _, c := range cases {
got, ct, err := p.ValidateUpload(c.in)
if (err == nil) != c.ok {
t.Errorf("%s: err=%v, want ok=%v", c.in, err, c.ok)
continue
}
if c.ok && (got != c.want || ct == "") {
t.Errorf("%s: got %q %q, want %q", c.in, got, ct, c.want)
}
}
}
func writeZip(t *testing.T, files map[string]string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "m.zip")
f, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
zw := zip.NewWriter(f)
for name, body := range files {
w, err := zw.Create(name)
if err != nil {
t.Fatal(err)
}
if _, err := w.Write([]byte(body)); err != nil {
t.Fatal(err)
}
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
if err := f.Close(); err != nil {
t.Fatal(err)
}
return path
}
func writeFile(t *testing.T, body string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "f")
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
return path
}
func TestValidateContentZip(t *testing.T) {
p := &Provider{}
sp := "example.unkin.net/hello/@v/v0.1.0.zip"
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module example.unkin.net/hello\n"), 0o600)
os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\n"), 0o600)
good := filepath.Join(t.TempDir(), "good.zip")
f, _ := os.Create(good)
if err := modzip.CreateFromDir(f, module.Version{Path: "example.unkin.net/hello", Version: "v0.1.0"}, dir); err != nil {
t.Fatal(err)
}
f.Close()
if err := p.ValidateContent(sp, good); err != nil {
t.Errorf("valid zip rejected: %v", err)
}
bad := map[string]map[string]string{
"no prefix": {"go.mod": "module example.unkin.net/hello\n"},
"wrong version": {"example.unkin.net/hello@v0.2.0/go.mod": "module example.unkin.net/hello\n"},
"wrong module": {"example.unkin.net/other@v0.1.0/go.mod": "module example.unkin.net/other\n"},
}
for name, files := range bad {
if err := p.ValidateContent(sp, writeZip(t, files)); err == nil {
t.Errorf("%s: expected rejection", name)
}
}
if err := p.ValidateContent(sp, writeFile(t, "not a zip")); err == nil {
t.Error("non-zip accepted")
}
}
func TestValidateContentModInfo(t *testing.T) {
p := &Provider{}
mod := "example.unkin.net/hello/@v/v0.1.0.mod"
info := "example.unkin.net/hello/@v/v0.1.0.info"
cases := []struct {
path, body string
ok bool
}{
{mod, "module example.unkin.net/hello\n\ngo 1.25\n", true},
{mod, "module example.unkin.net/other\n", false},
{mod, "go 1.25\n", false},
{mod, "module (\n", false},
{info, `{"Version":"v0.1.0","Time":"2026-01-01T00:00:00Z"}`, true},
{info, `{"Version":"v0.2.0"}`, false},
{info, `nope`, false},
}
for _, c := range cases {
if err := p.ValidateContent(c.path, writeFile(t, c.body)); (err == nil) != c.ok {
t.Errorf("%s %q: err=%v, want ok=%v", c.path, c.body, err, c.ok)
}
}
}
func serve(t *testing.T, fs provider.FileStore, path string) (bool, int, string) {
t.Helper()
rec := httptest.NewRecorder()
handled := (&Provider{}).ServeLocalIndex(rec, httptest.NewRequest("GET", "/"+path, nil), fs, "go-local", path)
return handled, rec.Code, rec.Body.String()
}
func TestServeLocalIndex(t *testing.T) {
fs := &fakeFileStore{paths: []string{
"example.unkin.net/hello/@v/v0.10.0.mod",
"example.unkin.net/hello/@v/v0.10.0.zip",
"example.unkin.net/hello/@v/v0.2.0.mod",
"example.unkin.net/hello/@v/v0.2.0.zip",
"example.unkin.net/hello/@v/v0.2.0.info",
"example.unkin.net/hello/@v/v0.11.0-rc.1.mod",
"example.unkin.net/hello/@v/v0.3.0.zip",
"example.unkin.net/hello/v2/@v/v2.0.0.mod",
"example.unkin.net/!pre/@v/v1.0.0-beta.mod",
}}
if h, code, body := serve(t, fs, "example.unkin.net/hello/@v/list"); !h || code != 200 || body != "v0.2.0\nv0.10.0\nv0.11.0-rc.1\n" {
t.Errorf("list: %v %d %q", h, code, body)
}
if h, code, body := serve(t, fs, "example.unkin.net/hello/@latest"); !h || code != 200 || body != `{"Version":"v0.10.0"}` {
t.Errorf("latest: %v %d %q", h, code, body)
}
if h, _, body := serve(t, fs, "example.unkin.net/!pre/@latest"); !h || body != `{"Version":"v1.0.0-beta"}` {
t.Errorf("prerelease-only latest: %q", body)
}
if h, code, _ := serve(t, fs, "example.unkin.net/missing/@latest"); !h || code != 404 {
t.Errorf("missing latest: %v %d", h, code)
}
if h, code, body := serve(t, fs, "example.unkin.net/missing/@v/list"); !h || code != 200 || body != "" {
t.Errorf("missing list: %v %d %q", h, code, body)
}
if h, _, body := serve(t, fs, "example.unkin.net/hello/@v/v0.10.0.info"); !h || body != `{"Version":"v0.10.0"}` {
t.Errorf("synthesized info: %v %q", h, body)
}
if h, _, _ := serve(t, fs, "example.unkin.net/hello/@v/v0.2.0.info"); h {
t.Error("uploaded .info must be served from storage")
}
if h, _, _ := serve(t, fs, "example.unkin.net/hello/@v/v0.3.0.info"); h {
t.Error(".info without a .mod must not be synthesized")
}
if h, _, _ := serve(t, fs, "example.unkin.net/hello/@v/v0.10.0.zip"); h {
t.Error(".zip must be served from storage")
}
}
+3
View File
@@ -8,6 +8,9 @@ import (
"git.unkin.net/unkin/artifactapi/pkg/models"
)
// RemoteMountPath is the URL path remote proxies are served under.
const RemoteMountPath = "/api/v1/remote/"
type Mutability int
const (
+43 -33
View File
@@ -29,7 +29,7 @@ const filesPrefix = "v3/files/"
// tarball basename that `puppet module build` produces.
var releaseRe = regexp.MustCompile(`^([a-z0-9]+)-([a-z][a-z0-9_]*)-(\d+\.\d+\.\d+(?:-[0-9a-z.-]+)?(?:\+[0-9a-z.-]+)?)$`)
func parseRelease(s string) (slug, version string, ok bool) {
func ParseRelease(s string) (slug, version string, ok bool) {
m := releaseRe.FindStringSubmatch(strings.ToLower(s))
if m == nil {
return "", "", false
@@ -37,7 +37,12 @@ func parseRelease(s string) (slug, version string, ok bool) {
return m[1] + "-" + m[2], m[3], true
}
func normalizeSlug(s string) string {
var slugRe = regexp.MustCompile(`^[a-z0-9]+-[a-z][a-z0-9_]*$`)
// ValidSlug reports whether s is a normalized <owner>-<name> module slug.
func ValidSlug(s string) bool { return slugRe.MatchString(s) }
func NormalizeSlug(s string) string {
return strings.ToLower(strings.ReplaceAll(s, "/", "-"))
}
@@ -45,7 +50,7 @@ func normalizeSlug(s string) string {
// resolve straight to the stored file.
func (p *Provider) ValidateUpload(filePath string) (storagePath, contentType string, err error) {
filename := strings.ToLower(path.Base(path.Clean("/" + filePath)))
if _, _, ok := parseRelease(strings.TrimSuffix(filename, ".tar.gz")); !ok || !strings.HasSuffix(filename, ".tar.gz") {
if _, _, ok := ParseRelease(strings.TrimSuffix(filename, ".tar.gz")); !ok || !strings.HasSuffix(filename, ".tar.gz") {
return "", "", fmt.Errorf("file must be named <owner>-<module>-<version>.tar.gz")
}
return filesPrefix + filename, "application/gzip", nil
@@ -82,7 +87,7 @@ func (p *Provider) AfterUpload(ctx context.Context, repoName, storagePath, conte
slog.Error("puppet metadata: read failed", "repo", repoName, "path", storagePath, "error", err)
return
}
slug, version, _ := parseRelease(strings.TrimSuffix(path.Base(storagePath), ".tar.gz"))
slug, version, _ := ParseRelease(strings.TrimSuffix(path.Base(storagePath), ".tar.gz"))
if meta.Slug != slug || meta.Version != version {
slog.Error("puppet metadata: metadata.json does not match filename", "repo", repoName, "path", storagePath, "name", meta.Slug, "version", meta.Version)
return
@@ -158,7 +163,7 @@ func parseModule(r io.Reader) (*provider.PuppetMetadata, error) {
if md.Name == "" || md.Version == "" {
return nil, errors.New("metadata.json missing name or version")
}
return &provider.PuppetMetadata{Slug: normalizeSlug(md.Name), Version: md.Version, MetadataJSON: string(body)}, nil
return &provider.PuppetMetadata{Slug: NormalizeSlug(md.Name), Version: md.Version, MetadataJSON: string(body)}, nil
}
}
@@ -181,9 +186,9 @@ func (p *Provider) ServeLocalIndex(w http.ResponseWriter, r *http.Request, files
case clean == "v3/releases":
serveReleaseList(w, r, list)
case strings.HasPrefix(clean, "v3/releases/"):
slug, version, ok := parseRelease(strings.TrimPrefix(clean, "v3/releases/"))
slug, version, ok := ParseRelease(strings.TrimPrefix(clean, "v3/releases/"))
if !ok {
notFound(w)
NotFound(w)
return true
}
metas, err := list(slug)
@@ -193,23 +198,23 @@ func (p *Provider) ServeLocalIndex(w http.ResponseWriter, r *http.Request, files
}
for _, m := range metas {
if m.Version == version {
writeJSON(w, releaseObject(m))
WriteJSON(w, releaseObject(m))
return true
}
}
notFound(w)
NotFound(w)
case strings.HasPrefix(clean, "v3/modules/"):
slug := normalizeSlug(strings.TrimPrefix(clean, "v3/modules/"))
slug := NormalizeSlug(strings.TrimPrefix(clean, "v3/modules/"))
metas, err := list(slug)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return true
}
if len(metas) == 0 {
notFound(w)
NotFound(w)
return true
}
writeJSON(w, moduleObject(metas))
WriteJSON(w, moduleObject(metas))
default:
return false
}
@@ -222,7 +227,22 @@ func (p *Provider) GenerateLocalIndex(ctx context.Context, files provider.FileSt
func serveReleaseList(w http.ResponseWriter, r *http.Request, list func(string) ([]provider.PuppetMetadata, error)) {
q := r.URL.Query()
slug := normalizeSlug(q.Get("module"))
slug := NormalizeSlug(q.Get("module"))
metas, err := list(slug)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
releases := make([]any, len(metas))
for i, m := range metas {
releases[i] = releaseObject(m)
}
WriteJSON(w, ReleasesPage(slug, releases, q))
}
// ReleasesPage is one limit/offset page of a /v3/releases listing, with
// pagination links relative to the Forge root.
func ReleasesPage(slug string, releases []any, q url.Values) map[string]any {
limit, err := strconv.Atoi(q.Get("limit"))
if err != nil || limit <= 0 {
limit = 20
@@ -232,20 +252,10 @@ func serveReleaseList(w http.ResponseWriter, r *http.Request, list func(string)
if err != nil || offset < 0 {
offset = 0
}
metas, err := list(slug)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
offset = min(offset, len(metas))
results := []map[string]any{}
for _, m := range metas[offset : offset+min(limit, len(metas)-offset)] {
results = append(results, releaseObject(m))
}
offset = min(offset, len(releases))
page := func(off int) any {
if off < 0 || off >= len(metas) && off != 0 {
if off < 0 || off >= len(releases) && off != 0 {
return nil
}
v := url.Values{"limit": {strconv.Itoa(limit)}, "offset": {strconv.Itoa(off)}}
@@ -258,7 +268,7 @@ func serveReleaseList(w http.ResponseWriter, r *http.Request, list func(string)
if offset > 0 {
previous = page(max(offset-limit, 0))
}
writeJSON(w, map[string]any{
return map[string]any{
"pagination": map[string]any{
"limit": limit,
"offset": offset,
@@ -266,10 +276,10 @@ func serveReleaseList(w http.ResponseWriter, r *http.Request, list func(string)
"previous": previous,
"current": page(offset),
"next": page(offset + limit),
"total": len(metas),
"total": len(releases),
},
"results": results,
})
"results": releases[offset : offset+min(limit, len(releases)-offset)],
}
}
func owner(slug string) map[string]any {
@@ -329,7 +339,7 @@ func moduleObject(metas []provider.PuppetMetadata) map[string]any {
return obj
}
func writeJSON(w http.ResponseWriter, v any) {
func WriteJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
enc := json.NewEncoder(w)
@@ -337,7 +347,7 @@ func writeJSON(w http.ResponseWriter, v any) {
_ = enc.Encode(v)
}
func notFound(w http.ResponseWriter) {
func NotFound(w http.ResponseWriter) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
_, _ = io.WriteString(w, `{"message":"404 Not Found","errors":["The requested resource could not be found"]}`)
@@ -349,11 +359,11 @@ func sortReleases(metas []provider.PuppetMetadata) {
if c := strings.Compare(a.Slug, b.Slug); c != 0 {
return c
}
return compareSemver(b.Version, a.Version)
return CompareSemver(b.Version, a.Version)
})
}
func compareSemver(a, b string) int {
func CompareSemver(a, b string) int {
a, _, _ = strings.Cut(a, "+")
b, _, _ = strings.Cut(b, "+")
aCore, aPre, aHasPre := strings.Cut(a, "-")
+2 -2
View File
@@ -291,11 +291,11 @@ func TestFilesFallThrough(t *testing.T) {
func TestCompareSemver(t *testing.T) {
ordered := []string{"0.1.0", "0.2.0", "0.10.0", "1.0.0-alpha", "1.0.0-alpha.1", "1.0.0-beta", "1.0.0-beta.2", "1.0.0-beta.11", "1.0.0", "1.0.1+build"}
for i := 1; i < len(ordered); i++ {
if compareSemver(ordered[i-1], ordered[i]) >= 0 || compareSemver(ordered[i], ordered[i-1]) <= 0 {
if CompareSemver(ordered[i-1], ordered[i]) >= 0 || CompareSemver(ordered[i], ordered[i-1]) <= 0 {
t.Errorf("%s should sort before %s", ordered[i-1], ordered[i])
}
}
if compareSemver("1.0.0+a", "1.0.0+b") != 0 {
if CompareSemver("1.0.0+a", "1.0.0+b") != 0 {
t.Error("build metadata must not affect precedence")
}
}
+27
View File
@@ -3,6 +3,7 @@ package puppet
import (
"context"
"net/http"
"strconv"
"strings"
"git.unkin.net/unkin/artifactapi/internal/auth"
@@ -39,6 +40,32 @@ func (p *Provider) UpstreamURL(remote models.Remote, path string) string {
return strings.TrimRight(remote.BaseURL, "/") + "/" + strings.TrimLeft(path, "/")
}
// ForwardedQuery keeps the module filter and pagination on release listings.
func (p *Provider) ForwardedQuery(path string) map[string]provider.QueryValidator {
if path != "v3/releases" {
return nil
}
return map[string]provider.QueryValidator{"module": moduleParam, "limit": pageLimit, "offset": pageOffset}
}
func moduleParam(v string) (string, bool) {
v = NormalizeSlug(v)
return v, ValidSlug(v)
}
func pageLimit(v string) (string, bool) {
n, err := strconv.ParseUint(v, 10, 32)
if err != nil || n == 0 {
return "", false
}
return strconv.FormatUint(min(n, 100), 10), true
}
func pageOffset(v string) (string, bool) {
n, err := strconv.ParseUint(v, 10, 32)
return strconv.FormatUint(n, 10), err == nil
}
func (p *Provider) RewritesResponse() {}
func (p *Provider) RewriteResponse(body []byte, remote models.Remote, proxyBaseURL string) ([]byte, error) {
+25
View File
@@ -76,3 +76,28 @@ func TestAuthHeaders(t *testing.T) {
t.Error("no credentials, no header")
}
}
func TestForwardedQuery(t *testing.T) {
p := &Provider{}
if p.ForwardedQuery("v3/files/a-b-1.0.0.tar.gz") != nil || p.ForwardedQuery("v3/modules/a-b") != nil {
t.Fatal("only release listings forward a query")
}
f := p.ForwardedQuery("v3/releases")
for name, c := range map[string]struct {
in, want string
ok bool
}{
"module": {"Puppetlabs/stdlib", "puppetlabs-stdlib", true},
"limit": {"500", "100", true},
"offset": {"40", "40", true},
} {
if got, ok := f[name](c.in); got != c.want || ok != c.ok {
t.Errorf("%s(%q) = %q %v, want %q %v", name, c.in, got, ok, c.want, c.ok)
}
}
for name, bad := range map[string]string{"module": "../x", "limit": "0", "offset": "-1"} {
if _, ok := f[name](bad); ok {
t.Errorf("%s(%q) accepted", name, bad)
}
}
}
+128
View File
@@ -15,9 +15,11 @@ import (
"regexp"
"strings"
"sync"
"sync/atomic"
"time"
rpmlib "github.com/cavaliergopher/rpm"
"golang.org/x/sync/singleflight"
"golang.org/x/time/rate"
"git.unkin.net/unkin/artifactapi/internal/githubauth"
@@ -52,6 +54,9 @@ const (
// retryable 503. Kept short so a client never hangs on a rate-limited derive
// of a large repo; small repos usually prime within this window.
defaultColdWait = 8 * time.Second
// repodataIdleTTL evicts cached indexes of remotes deleted or switched off.
repodataIdleTTL = time.Hour
repodataSweepEvery = time.Minute
)
// GitHubProvider is a metadata-only remote: it scans a GitHub repo's releases
@@ -84,6 +89,26 @@ type GitHubProvider struct {
mu sync.Mutex
scanning map[string]bool
lastScan map[string]time.Time
// repodata caches generated indexes for cache_repodata remotes, keyed by remote name.
repodataMu sync.Mutex
repodataCache map[string]cachedRepodata
repodataGroup singleflight.Group
repodataSeq atomic.Int64
repodataSwept time.Time
}
// rpmFingerprinter is a cheap change detector over a remote's metadata rows.
// Rows are insert-or-delete only, so (count, max id) changes whenever they do.
type rpmFingerprinter interface {
RPMMetadataFingerprint(ctx context.Context, repoName string) (count, maxID int64, err error)
}
type cachedRepodata struct {
count, maxID int64
seq int64 // build order, so a slow older build never replaces a newer one
used time.Time
*repodata
}
func newGitHubProvider() *GitHubProvider {
@@ -97,6 +122,7 @@ func newGitHubProvider() *GitHubProvider {
coldWait: defaultColdWait,
scanning: map[string]bool{},
lastScan: map[string]time.Time{},
repodataCache: map[string]cachedRepodata{},
}
}
@@ -170,6 +196,11 @@ func (p *GitHubProvider) ServeRemote(w http.ResponseWriter, r *http.Request, rem
}
tail := strings.TrimPrefix(path, "repodata/")
if fp, ok := store.(rpmFingerprinter); ok && remote.CacheRepodata {
p.serveCachedRepodata(w, sr, store, fp, remote.Name, tail)
return true
}
p.dropRepodata(remote.Name)
lp := &Provider{}
switch {
case tail == "repomd.xml":
@@ -239,7 +270,104 @@ func (p *GitHubProvider) ensurePrimed(ctx context.Context, remote models.Remote,
return false
}
// serveCachedRepodata serves a file from the generated index, rebuilding it
// only when the rows' fingerprint moved. The fingerprint is read from the shared
// database on every request, so each replica rebuilds as soon as any replica's
// scan changes rows and repomd never outlives the files it references.
func (p *GitHubProvider) serveCachedRepodata(w http.ResponseWriter, r *http.Request, store provider.RPMMetadataReader, fp rpmFingerprinter, name, tail string) {
var pick func(*repodata) []byte
contentType := "application/gzip"
switch {
case tail == "repomd.xml":
pick, contentType = func(rd *repodata) []byte { return rd.repomd }, "application/xml"
case strings.HasSuffix(tail, "-primary.xml.gz"):
pick = func(rd *repodata) []byte { return rd.primary }
case strings.HasSuffix(tail, "-filelists.xml.gz"):
pick = func(rd *repodata) []byte { return rd.filelists }
case strings.HasSuffix(tail, "-other.xml.gz"):
pick = func(rd *repodata) []byte { return rd.other }
default:
http.Error(w, "not found", http.StatusNotFound)
return
}
rd, err := p.cachedRepodata(r.Context(), store, fp, name)
if err != nil {
writeMetadataError(w, name, err)
return
}
w.Header().Set("Content-Type", contentType)
w.WriteHeader(http.StatusOK)
_, _ = w.Write(pick(rd))
}
// cachedRepodata returns the index for the current fingerprint, building it at
// most once per fingerprint. ctx is ServeRemote's detached serve context, so a
// leader's client disconnect cannot fail the build for coalesced waiters.
func (p *GitHubProvider) cachedRepodata(ctx context.Context, store provider.RPMMetadataReader, fp rpmFingerprinter, name string) (*repodata, error) {
seq := p.repodataSeq.Add(1)
count, maxID, err := fp.RPMMetadataFingerprint(ctx, name)
if err != nil {
return nil, err
}
now := time.Now()
p.repodataMu.Lock()
p.sweepRepodataLocked(now)
c, ok := p.repodataCache[name]
if ok {
c.used = now
p.repodataCache[name] = c
}
p.repodataMu.Unlock()
if ok && c.count == count && c.maxID == maxID {
return c.repodata, nil
}
v, err, _ := p.repodataGroup.Do(fmt.Sprintf("%s\x00%d\x00%d", name, count, maxID), func() (any, error) {
metas, err := store.ListRPMMetadataEntries(ctx, name)
if err != nil {
return nil, err
}
// Rows read after the fingerprint can only be newer than it, so the
// worst case is one extra rebuild on the next request.
rd := buildRepodata(metas)
p.repodataMu.Lock()
if cur, ok := p.repodataCache[name]; !ok || cur.seq < seq {
p.repodataCache[name] = cachedRepodata{count: count, maxID: maxID, seq: seq, used: time.Now(), repodata: rd}
}
p.repodataMu.Unlock()
return rd, nil
})
if err != nil {
return nil, err
}
return v.(*repodata), nil
}
func (p *GitHubProvider) dropRepodata(name string) {
p.repodataMu.Lock()
delete(p.repodataCache, name)
p.sweepRepodataLocked(time.Now())
p.repodataMu.Unlock()
}
// sweepRepodataLocked evicts idle entries, at most once per repodataSweepEvery.
func (p *GitHubProvider) sweepRepodataLocked(now time.Time) {
if now.Sub(p.repodataSwept) < repodataSweepEvery {
return
}
p.repodataSwept = now
for name, c := range p.repodataCache {
if now.Sub(c.used) > repodataIdleTTL {
delete(p.repodataCache, name)
}
}
}
func (p *GitHubProvider) cacheEmpty(ctx context.Context, store provider.RemoteMetadataStore, name string) bool {
if fp, ok := store.(rpmFingerprinter); ok {
count, _, err := fp.RPMMetadataFingerprint(ctx, name)
return err == nil && count == 0
}
rows, err := store.ListRPMMetadataEntries(ctx, name)
if err != nil {
// Treat a failed read as "not empty" so a transient DB error becomes a
@@ -0,0 +1,365 @@
package rpm
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"runtime"
"strings"
"sync"
"testing"
"time"
"git.unkin.net/unkin/artifactapi/internal/provider"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
// fpStore adds a fingerprint and a full-read counter to fakeStore.
type fpStore struct {
*fakeStore
lists, fps int
seq int64
fpErr, listErr error
gate chan struct{} // when set, full reads block until it is closed
}
func (s *fpStore) ListRPMMetadataEntries(ctx context.Context, name string) ([]provider.RPMMetadata, error) {
s.mu.Lock()
s.lists++
gate, err := s.gate, s.listErr
s.mu.Unlock()
if gate != nil {
<-gate
}
if err != nil {
return nil, err
}
return s.fakeStore.ListRPMMetadataEntries(ctx, name)
}
func (s *fpStore) InsertRPMMetadata(ctx context.Context, m *provider.RPMMetadata) error {
s.mu.Lock()
_, exists := s.rows[m.FilePath]
s.mu.Unlock()
if err := s.fakeStore.InsertRPMMetadata(ctx, m); err != nil || exists {
return err // ON CONFLICT DO NOTHING leaves max(id) unchanged
}
s.mu.Lock()
s.seq++
s.mu.Unlock()
return nil
}
func (s *fpStore) RPMMetadataFingerprint(_ context.Context, _ string) (int64, int64, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.fps++
return int64(len(s.rows)), s.seq, s.fpErr
}
func (s *fpStore) fpCount() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.fps
}
func (s *fpStore) listCount() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.lists
}
func newCacheFixture(t *testing.T, cache bool) (*GitHubProvider, *fpStore, models.Remote) {
t.Helper()
remote := models.Remote{Name: "cached-rpm", PackageType: models.PackageGitHubRPM, MutableTTL: 3600, CacheRepodata: cache}
p := newGitHubProvider()
p.lastScan[remote.Name] = time.Now()
store := &fpStore{fakeStore: newFakeStore()}
addPkg(t, store, "alpha")
return p, store, remote
}
func addPkg(t *testing.T, store *fpStore, name string) {
t.Helper()
if err := store.InsertRPMMetadata(context.Background(), &provider.RPMMetadata{
RepoName: "cached-rpm", FilePath: name + "-1-1.noarch.rpm", ContentHash: "sha256:" + name,
Name: name, Version: "1", Release: "1", Arch: "noarch", Group: "Unspecified",
Files: []provider.RPMFile{{Path: "/usr/bin/" + name}},
}); err != nil {
t.Fatal(err)
}
}
func serveReq(t *testing.T, p *GitHubProvider, remote models.Remote, store *fpStore, req *http.Request, path string) *httptest.ResponseRecorder {
t.Helper()
rec := httptest.NewRecorder()
if !p.ServeRemote(rec, req, remote, path, "https://proxy.example", store) {
t.Errorf("%s not handled", path)
}
return rec
}
func serve(t *testing.T, p *GitHubProvider, remote models.Remote, store *fpStore, path string) []byte {
t.Helper()
rec := serveReq(t, p, remote, store, httptest.NewRequest(http.MethodGet, "/x", nil), path)
if rec.Code != http.StatusOK {
t.Fatalf("%s: code %d: %s", path, rec.Code, rec.Body.String())
}
return rec.Body.Bytes()
}
var repodataPaths = []string{
"repodata/repomd.xml",
"repodata/x-primary.xml.gz",
"repodata/x-filelists.xml.gz",
"repodata/x-other.xml.gz",
}
func TestCachedRepodataHitSkipsRegeneration(t *testing.T) {
p, store, remote := newCacheFixture(t, true)
repomd := serve(t, p, remote, store, "repodata/repomd.xml")
primary := serve(t, p, remote, store, "repodata/x-primary.xml.gz")
if n := store.listCount(); n != 1 {
t.Fatalf("full metadata reads = %d, want 1", n)
}
if !strings.Contains(string(repomd), sha256Hex(primary)+"-primary.xml.gz") {
t.Fatalf("cached repomd does not reference cached primary:\n%s", repomd)
}
}
func TestCachedRepodataRebuildsOnDataChange(t *testing.T) {
p, store, remote := newCacheFixture(t, true)
before := serve(t, p, remote, store, "repodata/repomd.xml")
addPkg(t, store, "beta")
after := serve(t, p, remote, store, "repodata/repomd.xml")
primary := serve(t, p, remote, store, "repodata/x-primary.xml.gz")
if string(before) == string(after) {
t.Fatal("repomd unchanged after metadata change")
}
if !strings.Contains(string(after), sha256Hex(primary)+"-primary.xml.gz") {
t.Fatal("rebuilt repomd does not reference rebuilt primary")
}
if !strings.Contains(gunzip(t, primary), "<name>beta</name>") {
t.Fatal("rebuilt primary missing new package")
}
if n := store.listCount(); n != 2 {
t.Fatalf("full metadata reads = %d, want 2", n)
}
}
func TestUncachedRepodataMatchesCachedAndReadsEveryTime(t *testing.T) {
p, store, remote := newCacheFixture(t, true)
cached := map[string]string{}
for _, path := range repodataPaths {
cached[path] = string(serve(t, p, remote, store, path))
}
remote.CacheRepodata = false
reads := store.listCount()
for _, path := range repodataPaths {
if got := string(serve(t, p, remote, store, path)); got != cached[path] {
t.Fatalf("%s differs between cached and uncached", path)
}
}
if n := store.listCount() - reads; n != len(repodataPaths) {
t.Fatalf("uncached full reads = %d, want %d", n, len(repodataPaths))
}
if _, ok := p.repodataCache[remote.Name]; ok {
t.Fatal("disabling cache_repodata should drop the cached index")
}
}
func TestCachedRepodataRebuildsOnSameCountReinsert(t *testing.T) {
p, store, remote := newCacheFixture(t, true)
addPkg(t, store, "alpha") // conflicting insert: fingerprint must not move
serve(t, p, remote, store, "repodata/repomd.xml")
serve(t, p, remote, store, "repodata/repomd.xml")
if n := store.listCount(); n != 1 {
t.Fatalf("full reads after conflicting insert = %d, want 1", n)
}
if err := store.DeleteRPMMetadata(context.Background(), remote.Name, "alpha-1-1.noarch.rpm"); err != nil {
t.Fatal(err)
}
addPkg(t, store, "alpha")
serve(t, p, remote, store, "repodata/repomd.xml")
if n := store.listCount(); n != 2 {
t.Fatalf("full reads after same-count reinsert = %d, want 2", n)
}
}
func TestCachedRepodataEvictsIdleRemotes(t *testing.T) {
p, store, remote := newCacheFixture(t, true)
serve(t, p, remote, store, "repodata/repomd.xml")
p.repodataCache["gone"] = cachedRepodata{used: time.Now().Add(-2 * repodataIdleTTL), repodata: &repodata{}}
p.repodataSwept = time.Time{}
serve(t, p, remote, store, "repodata/repomd.xml")
if _, ok := p.repodataCache["gone"]; ok {
t.Fatal("idle remote's index not evicted")
}
if _, ok := p.repodataCache[remote.Name]; !ok {
t.Fatal("active remote's index evicted")
}
}
func waitFor(t *testing.T, cond func() bool) {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
for !cond() {
if time.Now().After(deadline) {
t.Fatal("condition not reached")
}
time.Sleep(time.Millisecond)
}
}
// sfWaiters counts goroutines parked on an in-flight singleflight call.
func sfWaiters() int {
buf := make([]byte, 1<<20)
for {
n := runtime.Stack(buf, true)
if n < len(buf) {
buf = buf[:n]
break
}
buf = make([]byte, 2*len(buf))
}
waiters := 0
for _, g := range strings.Split(string(buf), "\n\n") {
if strings.Contains(g, "sync.(*WaitGroup).Wait") && strings.Contains(g, "singleflight.(*Group).Do(") {
waiters++
}
}
return waiters
}
func TestCachedRepodataCoalescesConcurrentBuilds(t *testing.T) {
p, store, remote := newCacheFixture(t, true)
gate := make(chan struct{})
store.gate = gate
const n = 8
var wg sync.WaitGroup
codes := make([]int, n)
for i := range n {
wg.Add(1)
go func() {
defer wg.Done()
codes[i] = serveReq(t, p, remote, store, httptest.NewRequest(http.MethodGet, "/x", nil), repodataPaths[i%len(repodataPaths)]).Code
}()
}
waitFor(t, func() bool { return store.listCount() == 1 && sfWaiters() == n-1 })
close(gate)
wg.Wait()
for i, c := range codes {
if c != http.StatusOK {
t.Errorf("request %d: code %d", i, c)
}
}
if got := store.listCount(); got != 1 {
t.Fatalf("full metadata reads = %d, want 1", got)
}
}
func TestCachedRepodataLeaderCancelDoesNotFailWaiters(t *testing.T) {
p, store, remote := newCacheFixture(t, true)
gate := make(chan struct{})
store.gate = gate
leaderCtx, cancelLeader := context.WithCancel(context.Background())
results := make(chan int, 2)
go func() {
req := httptest.NewRequest(http.MethodGet, "/x", nil).WithContext(leaderCtx)
results <- serveReq(t, p, remote, store, req, "repodata/repomd.xml").Code
}()
waitFor(t, func() bool { return store.listCount() == 1 })
go func() {
results <- serveReq(t, p, remote, store, httptest.NewRequest(http.MethodGet, "/x", nil), "repodata/repomd.xml").Code
}()
waitFor(t, func() bool { return sfWaiters() == 1 })
cancelLeader()
close(gate)
for range 2 {
if c := <-results; c != http.StatusOK {
t.Fatalf("code %d after leader cancel, want 200", c)
}
}
if got := store.listCount(); got != 1 {
t.Fatalf("full metadata reads = %d, want 1", got)
}
}
func TestCachedRepodataOlderBuildDoesNotOverwriteNewer(t *testing.T) {
p, store, remote := newCacheFixture(t, true)
newer := &repodata{}
p.repodataCache[remote.Name] = cachedRepodata{count: 99, maxID: 99, seq: 1 << 40, used: time.Now(), repodata: newer}
serve(t, p, remote, store, "repodata/repomd.xml")
if p.repodataCache[remote.Name].repodata != newer {
t.Fatal("older build replaced a newer cache entry")
}
}
func TestCachedRepodataErrors(t *testing.T) {
secret := errors.New("pq: password authentication failed for user secret")
for _, tc := range []struct {
name string
fpErr, listErr error
code int
}{
{"fingerprint", secret, nil, http.StatusInternalServerError},
{"list", nil, secret, http.StatusInternalServerError},
{"fingerprint deadline", context.DeadlineExceeded, nil, http.StatusServiceUnavailable},
{"list canceled", nil, context.Canceled, http.StatusServiceUnavailable},
} {
t.Run(tc.name, func(t *testing.T) {
p, store, remote := newCacheFixture(t, true)
store.fpErr, store.listErr = tc.fpErr, tc.listErr
rec := serveReq(t, p, remote, store, httptest.NewRequest(http.MethodGet, "/x", nil), "repodata/repomd.xml")
if rec.Code != tc.code {
t.Fatalf("code %d, want %d", rec.Code, tc.code)
}
if strings.Contains(rec.Body.String(), "secret") {
t.Fatalf("DB error leaked: %q", rec.Body.String())
}
if retry := rec.Header().Get("Retry-After"); (tc.code == http.StatusServiceUnavailable) != (retry != "") {
t.Fatalf("Retry-After = %q for %d", retry, tc.code)
}
if _, ok := p.repodataCache[remote.Name]; ok {
t.Fatal("failed build was cached")
}
})
}
}
func TestCachedRepodataUnknownTail404sWithoutDB(t *testing.T) {
p, store, remote := newCacheFixture(t, true)
rec := serveReq(t, p, remote, store, httptest.NewRequest(http.MethodGet, "/x", nil), "repodata/bogus.txt")
if rec.Code != http.StatusNotFound {
t.Fatalf("code %d, want 404", rec.Code)
}
if store.fpCount() != 0 || store.listCount() != 0 {
t.Fatalf("unknown tail hit the DB: fps=%d lists=%d", store.fpCount(), store.listCount())
}
}
func TestCacheEmptyUsesFingerprint(t *testing.T) {
p := newGitHubProvider()
store := &fpStore{fakeStore: newFakeStore()}
if !p.cacheEmpty(context.Background(), store, "cached-rpm") {
t.Fatal("empty store not reported empty")
}
addPkg(t, store, "alpha")
if p.cacheEmpty(context.Background(), store, "cached-rpm") {
t.Fatal("populated store reported empty")
}
store.fpErr = errors.New("db down")
if p.cacheEmpty(context.Background(), store, "cached-rpm") {
t.Fatal("fingerprint error must not report empty")
}
if store.listCount() != 0 {
t.Fatalf("cacheEmpty did full reads: %d", store.listCount())
}
}
+33 -17
View File
@@ -250,36 +250,52 @@ func (p *Provider) GenerateLocalIndex(ctx context.Context, files provider.FileSt
func readMetadataEntries(w http.ResponseWriter, r *http.Request, reader provider.RPMMetadataReader, repoName string) ([]provider.RPMMetadata, bool) {
metas, err := reader.ListRPMMetadataEntries(r.Context(), repoName)
if err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
slog.Warn("rpm: metadata read canceled", "repo", repoName, "error", err)
http.Error(w, "metadata read canceled", http.StatusServiceUnavailable)
return nil, false
}
http.Error(w, err.Error(), http.StatusInternalServerError)
writeMetadataError(w, repoName, err)
return nil, false
}
return metas, true
}
// writeMetadataError maps a metadata read failure to a response without leaking
// DB error text: a canceled/expired context is a retryable 503, anything else a 500.
func writeMetadataError(w http.ResponseWriter, repoName string, err error) {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
slog.Warn("rpm: metadata read canceled", "repo", repoName, "error", err)
w.Header().Set("Retry-After", "5")
http.Error(w, "metadata read canceled", http.StatusServiceUnavailable)
return
}
slog.Error("rpm: metadata read failed", "repo", repoName, "error", err)
http.Error(w, "metadata read failed", http.StatusInternalServerError)
}
func (p *Provider) serveRepomd(w http.ResponseWriter, r *http.Request, reader provider.RPMMetadataReader, repoName string) {
metas, ok := readMetadataEntries(w, r, reader, repoName)
if !ok {
return
}
primary := generatePrimaryXMLGZ(metas)
filelists := generateFilelistsXMLGZ(metas)
other := generateOtherXMLGZ(metas)
primaryHash := sha256Hex(primary)
filelistsHash := sha256Hex(filelists)
otherHash := sha256Hex(other)
repomd := generateRepomd(repomdRevision(metas), primaryHash, len(primary), filelistsHash, len(filelists), otherHash, len(other))
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusOK)
w.Write(repomd)
_, _ = w.Write(buildRepodata(metas).repomd)
}
// repodata is a complete generated index: repomd.xml plus the three gz files it references.
type repodata struct {
repomd, primary, filelists, other []byte
}
func buildRepodata(metas []provider.RPMMetadata) *repodata {
rd := &repodata{
primary: generatePrimaryXMLGZ(metas),
filelists: generateFilelistsXMLGZ(metas),
other: generateOtherXMLGZ(metas),
}
rd.repomd = generateRepomd(repomdRevision(metas),
sha256Hex(rd.primary), len(rd.primary),
sha256Hex(rd.filelists), len(rd.filelists),
sha256Hex(rd.other), len(rd.other))
return rd
}
func (p *Provider) servePrimary(w http.ResponseWriter, r *http.Request, reader provider.RPMMetadataReader, repoName string) {
+14 -12
View File
@@ -21,7 +21,7 @@ const (
// scan never loses its lease, while a crashed replica's lease still expires.
syncLeaseDuration = 15 * time.Minute
// defaultSyncFreshness is the periodic re-check interval used when a remote's
// mutable_ttl is unset.
// sync_interval and mutable_ttl are unset.
defaultSyncFreshness = 5 * time.Minute
// jobQueueDepth bounds the pending work queue; enqueues past it are dropped
// (a later poll re-enqueues), never blocking the caller.
@@ -34,7 +34,7 @@ const (
type SyncStore interface {
provider.RemoteMetadataStore
ListGitHubRPMRemotes(ctx context.Context) ([]models.Remote, error)
ClaimGitHubSyncLease(ctx context.Context, remoteName, owner string, freshness, lease time.Duration) (claimed bool, etag string, err error)
ClaimGitHubSyncLease(ctx context.Context, remoteName, owner string, freshness, lease, interval, phase time.Duration) (claimed bool, etag string, err error)
ReleaseGitHubSyncLease(ctx context.Context, remoteName, owner string, res provider.SyncResult) error
}
@@ -43,7 +43,7 @@ type SyncConfig struct {
RatePerSec float64 // global GitHub request rate (req/s)
Burst int // token-bucket burst
Workers int // concurrent scan workers
PollInterval time.Duration // base scheduler tick; per-remote cadence is mutable_ttl
PollInterval time.Duration // base scheduler tick; per-remote cadence is sync_interval or mutable_ttl
}
type syncJob struct {
@@ -139,9 +139,9 @@ func (s *Syncer) Run(ctx context.Context) {
}
// schedule enqueues a periodic check for every github_rpm remote. The DB lease
// (claimed in the worker) enforces the per-remote mutable_ttl cadence and cross
// replica coordination, so enqueuing every tick is cheap: a not-yet-due remote
// simply fails to claim and is skipped.
// (claimed in the worker) enforces the per-remote sync_interval (else
// mutable_ttl) cadence and cross replica coordination, so enqueuing every tick
// is cheap: a not-yet-due remote simply fails to claim and is skipped.
func (s *Syncer) schedule(ctx context.Context) {
remotes, err := s.store.ListGitHubRPMRemotes(ctx)
if err != nil {
@@ -155,8 +155,10 @@ func (s *Syncer) schedule(ctx context.Context) {
// EnqueuePrime queues an immediate background prime for a freshly created
// remote so its metadata is derived without blocking the create call.
// A sync_interval remote skips it and syncs in its first slot, so remotes
// created together do not burst.
func (s *Syncer) EnqueuePrime(remote models.Remote) {
if s == nil {
if s == nil || remote.SyncInterval > 0 {
return
}
s.enqueue(remote, true)
@@ -205,16 +207,16 @@ func (s *Syncer) process(ctx context.Context, job syncJob) {
s.mu.Unlock()
}()
ttl := time.Duration(job.remote.MutableTTL) * time.Second
if ttl <= 0 {
ttl = defaultSyncFreshness
}
ttl := job.remote.SyncPeriod(defaultSyncFreshness)
freshness := ttl
var interval, phase time.Duration
if job.prime {
freshness = 0 // prime ignores the recency gate but still respects a live lease
} else {
interval, phase = job.remote.SyncSchedule()
}
claimed, etag, err := s.store.ClaimGitHubSyncLease(ctx, job.remote.Name, s.owner, freshness, syncLeaseDuration)
claimed, etag, err := s.store.ClaimGitHubSyncLease(ctx, job.remote.Name, s.owner, freshness, syncLeaseDuration, interval, phase)
if err != nil {
slog.Error("github_rpm syncer: claim lease", "remote", job.remote.Name, "error", err)
return
+48 -2
View File
@@ -29,6 +29,10 @@ type fakeSyncStore struct {
etags map[string]string
retryAt map[string]time.Time
lastResult provider.SyncResult
freshness time.Duration
interval time.Duration
phase time.Duration
attempts int
}
func newFakeSyncStore() *fakeSyncStore {
@@ -48,9 +52,11 @@ func (f *fakeSyncStore) ListGitHubRPMRemotes(_ context.Context) ([]models.Remote
return append([]models.Remote(nil), f.remotes...), nil
}
func (f *fakeSyncStore) ClaimGitHubSyncLease(_ context.Context, name, owner string, freshness, lease time.Duration) (bool, string, error) {
func (f *fakeSyncStore) ClaimGitHubSyncLease(_ context.Context, name, owner string, freshness, lease, interval, phase time.Duration) (bool, string, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.freshness, f.interval, f.phase = freshness, interval, phase
f.attempts++
now := time.Now()
ls, hasLS := f.lastSynced[name]
exp, hasExp := f.leaseExp[name]
@@ -230,7 +236,7 @@ func TestSyncerLeasePreventsSecondReplica(t *testing.T) {
remote := fx.remote()
// Replica 1 holds the lease.
claimed, _, err := store.ClaimGitHubSyncLease(context.Background(), remote.Name, "replica-1", time.Duration(remote.MutableTTL)*time.Second, syncLeaseDuration)
claimed, _, err := store.ClaimGitHubSyncLease(context.Background(), remote.Name, "replica-1", time.Duration(remote.MutableTTL)*time.Second, syncLeaseDuration, 0, 0)
if err != nil || !claimed {
t.Fatalf("replica-1 claim: claimed=%v err=%v", claimed, err)
}
@@ -470,3 +476,43 @@ func TestSyncerFailedAssetRetry(t *testing.T) {
})
}
}
// A periodic check claims with sync_interval as its freshness and passes the
// remote's slot schedule; unset, it falls back to mutable_ttl with no schedule.
func TestSyncerHonoursSyncInterval(t *testing.T) {
for _, interval := range []int{0, 7200} {
store := newFakeSyncStore()
s := newSyncer(store, newTestProvider(), testSyncConfig())
store.lastSynced["sched"] = time.Now()
remote := models.Remote{Name: "sched", MutableTTL: 90, SyncInterval: interval}
wantInterval, wantPhase := remote.SyncSchedule()
s.process(context.Background(), syncJob{remote: remote})
if want := remote.SyncPeriod(0); store.freshness != want || store.interval != wantInterval || store.phase != wantPhase {
t.Errorf("sync_interval=%d: claim freshness/interval/phase = %v/%v/%v, want %v/%v/%v",
interval, store.freshness, store.interval, store.phase, want, wantInterval, wantPhase)
}
}
}
// Create-time primes are skipped for a scheduled remote so remotes created
// together spread out; a cold-cache prime still claims with no schedule.
func TestSyncerScheduledPrime(t *testing.T) {
store := newFakeSyncStore()
s := newSyncer(store, newTestProvider(), testSyncConfig())
remote := models.Remote{Name: "fresh", SyncInterval: 3600}
s.EnqueuePrime(remote)
if len(s.jobs) != 0 {
t.Fatal("create-time prime enqueued for a sync_interval remote")
}
s.process(context.Background(), syncJob{remote: remote, prime: true})
if store.attempts != 1 || store.freshness != 0 || store.interval != 0 {
t.Fatalf("cold-cache prime: attempts=%d freshness=%v interval=%v, want 1/0/0", store.attempts, store.freshness, store.interval)
}
}
func TestSyncLeaseOutlivesScanTimeout(t *testing.T) {
if syncLeaseDuration <= defaultScanTimeout {
t.Fatalf("syncLeaseDuration %v must exceed defaultScanTimeout %v so scans stay serialised", syncLeaseDuration, defaultScanTimeout)
}
}
+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 (
"regexp"
"strings"
"sync"
"git.unkin.net/unkin/artifactapi/internal/provider"
@@ -37,7 +38,9 @@ func NewClassifier(p provider.Provider) *Classifier {
return &Classifier{provider: p}
}
// Classify judges the bare path; a forwarded query must not change the verdict.
func (c *Classifier) Classify(remote models.Remote, path string) Classification {
path, _, _ = strings.Cut(path, "?")
if matchesAny(path, compilePatterns(remote.Blocklist)) {
return ClassDenied
}
+18
View File
@@ -38,6 +38,24 @@ func TestClassifierBranches(t *testing.T) {
}
}
func TestClassifierIgnoresForwardedQuery(t *testing.T) {
gp, err := provider.Get(models.PackageGeneric)
if err != nil {
t.Fatal(err)
}
c := NewClassifier(gp)
if got := c.Classify(models.Remote{Blocklist: []string{`\.exe$`}}, "x.exe?limit=1"); got != ClassDenied {
t.Errorf("blocklist bypassed by query: %v", got)
}
allow := models.Remote{Patterns: []string{`/versions/$`}}
if got := c.Classify(allow, "a/versions/?limit=1&offset=100"); got != ClassImmutable {
t.Errorf("allowlisted paginated path = %v, want immutable", got)
}
if got := c.Classify(models.Remote{MutablePatterns: []string{`/versions/$`}}, "a/versions/?offset=100"); got != ClassMutable {
t.Errorf("mutable pattern on paginated path = %v, want mutable", got)
}
}
func TestClassificationString(t *testing.T) {
for c, want := range map[Classification]string{
ClassImmutable: "immutable",
+77 -6
View File
@@ -217,7 +217,8 @@ func (e *Engine) Fetch(ctx context.Context, remote models.Remote, path string, p
// Evict drops path from every cache layer (artifact row, index object, Redis
// freshness and ETag keys) so the next request refetches from upstream. A
// trailing "/*" evicts every path under that directory.
// trailing "/*" evicts every path under that directory; a plain path also
// drops its forwarded-query variants ("path?...").
func (e *Engine) Evict(ctx context.Context, remoteName, path string) error {
prefix, wildcard := strings.CutSuffix(path, "*")
if wildcard && !strings.HasSuffix(prefix, "/") {
@@ -240,11 +241,23 @@ func (e *Engine) Evict(ctx context.Context, remoteName, path string) error {
if err := e.store.Delete(ctx, storage.IndexKey(remoteName, path)); err != nil {
return fmt.Errorf("delete index: %w", err)
}
return e.cache.ForgetPath(ctx, remoteName, path)
if err := e.cache.ForgetPath(ctx, remoteName, path); err != nil {
return err
}
if strings.Contains(path, "?") {
return nil
}
return e.evictPrefix(ctx, remoteName, path+"?")
}
// ponytail: no lock for wildcards; a Fetch already in flight under the
// prefix can re-cache its path after the evict. Per-path locks over a
// directory would close it if that ever matters.
return e.evictPrefix(ctx, remoteName, prefix)
}
// evictPrefix drops every cached path starting with prefix, including the
// forwarded-query variants ("path?...") of a single path.
func (e *Engine) evictPrefix(ctx context.Context, remoteName, prefix string) error {
if err := e.db.DeleteArtifactsByPrefix(ctx, remoteName, prefix); err != nil {
return fmt.Errorf("delete artifacts: %w", err)
}
@@ -312,12 +325,12 @@ func (e *Engine) Head(ctx context.Context, remote models.Remote, path string, pr
return &HeadResult{ContentType: info.ContentType, Size: info.Size, Source: "cache"}, nil
}
return e.headUpstream(ctx, remote, path, prov)
return e.headUpstream(ctx, remote, path, prov, class)
}
// headUpstream issues an upstream HEAD, load-balancing across the remote's base
// URLs and failing over to the next mirror on a network error or 5xx.
func (e *Engine) headUpstream(ctx context.Context, remote models.Remote, path string, prov provider.Provider) (*HeadResult, error) {
func (e *Engine) headUpstream(ctx context.Context, remote models.Remote, path string, prov provider.Provider, class Classification) (*HeadResult, error) {
order := e.baseURLAttemptOrder(remote)
if len(order) == 0 {
return nil, &ProxyError{Status: http.StatusBadGateway, Message: "no upstream base_url configured"}
@@ -325,7 +338,7 @@ func (e *Engine) headUpstream(ctx context.Context, remote models.Remote, path st
var lastErr error
for i, url := range order {
ctr := e.beginAttempt(remote, url)
result, err := e.headUpstreamOnce(ctx, withBaseURL(remote, url), path, prov)
result, err := e.headUpstreamOnce(ctx, withBaseURL(remote, url), path, prov, class)
endAttempt(ctr)
if err == nil {
return result, nil
@@ -340,7 +353,7 @@ func (e *Engine) headUpstream(ctx context.Context, remote models.Remote, path st
return nil, lastErr
}
func (e *Engine) headUpstreamOnce(ctx context.Context, remote models.Remote, path string, prov provider.Provider) (*HeadResult, error) {
func (e *Engine) headUpstreamOnce(ctx context.Context, remote models.Remote, path string, prov provider.Provider, class Classification) (*HeadResult, error) {
url := prov.UpstreamURL(remote, path)
authHeaders, err := prov.AuthHeaders(ctx, remote)
@@ -387,6 +400,11 @@ func (e *Engine) headUpstreamOnce(ctx context.Context, remote models.Remote, pat
if resp.StatusCode != http.StatusOK {
return nil, &ProxyError{Status: resp.StatusCode, Message: fmt.Sprintf("upstream returned %d", resp.StatusCode)}
}
if class == ClassImmutable {
if err := e.checkQuarantine(ctx, remote, path, resp.Header); err != nil {
return nil, err
}
}
contentType := prov.ContentType(path)
if ct := resp.Header.Get("Content-Type"); ct != "" {
@@ -514,6 +532,11 @@ func (e *Engine) fetchFromUpstreamOnce(ctx context.Context, remote models.Remote
}, nil
}
if err := e.checkQuarantine(ctx, remote, path, resp.Header); err != nil {
_ = resp.Body.Close()
return nil, err
}
// Immutable blobs are streamed through the content-addressable store
// (tempfile -> sha256 -> S3) so arbitrarily large artifacts never sit
// fully in memory. Immutable content is never rewritten in the proxy path.
@@ -650,6 +673,43 @@ func (e *Engine) checkUpstreamOnce(ctx context.Context, remote models.Remote, pa
return resp.StatusCode == http.StatusNotModified, nil
}
// checkQuarantine refuses an uncached immutable artifact published less than
// quarantine_days ago. The publish time is the upstream Last-Modified, capped
// at now, else the first time this path was seen.
func (e *Engine) checkQuarantine(ctx context.Context, remote models.Remote, path string, upstream http.Header) error {
if !remote.QuarantineEnabled {
return nil
}
if a, err := e.db.GetArtifact(ctx, remote.Name, path); err == nil && a != nil {
return nil
}
days := remote.QuarantineDays
if days < 1 {
days = models.DefaultQuarantineDays
}
window := time.Duration(days) * 24 * time.Hour
now := time.Now()
published, err := http.ParseTime(upstream.Get("Last-Modified"))
if err != nil {
if published, err = e.cache.FirstSeen(ctx, remote.Name, path, window+24*time.Hour); err != nil {
slog.Warn("first-seen lookup failed", "remote", remote.Name, "path", path, "error", err)
return &ProxyError{Status: http.StatusServiceUnavailable, Message: "quarantine check unavailable", RetryAfter: 30}
}
}
if published.After(now) {
published = now
}
if until := published.Add(window); now.Before(until) {
return &ProxyError{
Status: http.StatusForbidden,
Message: fmt.Sprintf("quarantined: published %s, available from %s", published.UTC().Format(time.RFC3339), until.UTC().Format(time.RFC3339)),
Published: published.UTC(),
Available: until.UTC(),
}
}
return nil
}
// etagTTL keeps a mutable path's ETag well past its freshness key so an expired
// path revalidates with a conditional HEAD, while idle paths still age out.
func etagTTL(ttl time.Duration) time.Duration {
@@ -808,10 +868,21 @@ func fetchBearerToken(ctx context.Context, wwwAuth string, remote models.Remote)
type ProxyError struct {
Status int
Message string
// Published and Available are set only on a quarantine refusal.
Published time.Time
Available time.Time
// RetryAfter, in seconds, is sent as Retry-After when set.
RetryAfter int
}
func (e *ProxyError) Error() string { return e.Message }
// Quarantined reports whether err is a quarantine refusal.
func Quarantined(err error) bool {
var pe *ProxyError
return errors.As(err, &pe) && !pe.Published.IsZero()
}
type UpstreamError struct {
Err error
}
+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}
start := time.Now()
_, err := (&Engine{}).headUpstreamOnce(context.Background(), r, "blob.bin", prov(t, models.PackageGeneric))
_, err := (&Engine{}).headUpstreamOnce(context.Background(), r, "blob.bin", prov(t, models.PackageGeneric), ClassImmutable)
if err == nil || time.Since(start) > 2*time.Second {
t.Fatalf("HEAD ignored the remote's response header timeout: err=%v after %s", err, time.Since(start))
}
+25
View File
@@ -271,3 +271,28 @@ func TestEvictWildcardClearsPrefixOnly(t *testing.T) {
t.Errorf("path outside prefix = %q, want cached rev1", got)
}
}
func TestEvictPathDropsQueryVariants(t *testing.T) {
requireStack(t)
srv, _, _ := changingUpstream(t)
r := rpmRemote(t, "evict-query", srv.URL)
const (
path = "8/Everything/x86_64/repodata/repomd.xml"
variant = path + "?limit=10"
sibling = "8/Everything/x86_64/repodata/repomd.xml.asc"
)
for _, p := range []string{path, variant, sibling} {
fetchBody(t, r, p)
}
if err := testEngine.Evict(context.Background(), r.Name, path); err != nil {
t.Fatalf("evict: %v", err)
}
for _, p := range []string{path, variant} {
if c := layers(t, r.Name, p); c != (cached{}) {
t.Errorf("%s after evict = %+v, want every layer gone", p, c)
}
}
if c := layers(t, r.Name, sibling); c == (cached{}) {
t.Errorf("%s sharing the name prefix was evicted", sibling)
}
}
+186
View File
@@ -0,0 +1,186 @@
package proxy
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"git.unkin.net/unkin/artifactapi/internal/cache"
"git.unkin.net/unkin/artifactapi/pkg/models"
goredis "github.com/redis/go-redis/v9"
)
func must[T any](v T, err error) T {
if err != nil {
panic(err)
}
return v
}
func quarantineUpstream(t *testing.T) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/fresh.tgz":
w.Header().Set("Last-Modified", time.Now().Add(-time.Hour).UTC().Format(http.TimeFormat))
case "/old.tgz":
w.Header().Set("Last-Modified", time.Now().Add(-30*24*time.Hour).UTC().Format(http.TimeFormat))
case "/future.tgz":
w.Header().Set("Last-Modified", time.Now().Add(30*24*time.Hour).UTC().Format(http.TimeFormat))
case "/undated.tgz", "/pkg":
default:
http.NotFound(w, r)
return
}
_, _ = w.Write([]byte("body " + r.URL.Path))
}))
t.Cleanup(srv.Close)
return srv
}
func quarantineRemote(t *testing.T, name string, enabled bool) models.Remote {
r := models.Remote{Name: name, PackageType: models.PackageNPM, RepoType: models.RepoTypeRemote,
BaseURL: quarantineUpstream(t).URL, QuarantineEnabled: enabled, QuarantineDays: 3}
return seed(t, r)
}
func wantQuarantined(t *testing.T, err error) *ProxyError {
t.Helper()
var pe *ProxyError
if !errors.As(err, &pe) || pe.Status != http.StatusForbidden || !Quarantined(err) {
t.Fatalf("want quarantine 403, got %v", err)
}
return pe
}
func TestQuarantineBlocksFreshArtifact(t *testing.T) {
requireStack(t)
ctx := context.Background()
r := quarantineRemote(t, "q-fresh", true)
pe := wantQuarantined(t, func() error { _, err := testEngine.Fetch(ctx, r, "fresh.tgz", prov(t, models.PackageNPM)); return err }())
if d := time.Until(pe.Available); d < 70*time.Hour || d > 72*time.Hour {
t.Errorf("available in %s, want ~71h", d)
}
if a, _ := testDB.GetArtifact(ctx, r.Name, "fresh.tgz"); a != nil {
t.Error("quarantined artifact was cached")
}
_, err := testEngine.Head(ctx, r, "fresh.tgz", prov(t, models.PackageNPM))
_ = wantQuarantined(t, err)
}
func TestQuarantineServesOldArtifact(t *testing.T) {
requireStack(t)
r := quarantineRemote(t, "q-old", true)
res, err := testEngine.Fetch(context.Background(), r, "old.tgz", prov(t, models.PackageNPM))
if err != nil {
t.Fatal(err)
}
if got := readAll(t, res); got != "body /old.tgz" {
t.Errorf("body = %q", got)
}
}
func TestQuarantineFallsBackToFirstSeen(t *testing.T) {
requireStack(t)
ctx := context.Background()
r := quarantineRemote(t, "q-undated", true)
p := prov(t, models.PackageNPM)
_, err := testEngine.Fetch(ctx, r, "undated.tgz", p)
first := wantQuarantined(t, err).Published
if time.Since(first) > time.Minute {
t.Fatalf("first seen %s, want now", first)
}
_, err = testEngine.Fetch(ctx, r, "undated.tgz", p)
if again := wantQuarantined(t, err).Published; !again.Equal(first) {
t.Errorf("first seen moved: %s -> %s", first, again)
}
r.QuarantineDays = 1
c := goredis.NewClient(must(goredis.ParseURL(testRedis)))
defer func() { _ = c.Close() }()
if err := c.Set(ctx, "firstseen:q-undated:undated.tgz", time.Now().Add(-48*time.Hour).Unix(), time.Hour).Err(); err != nil {
t.Fatal(err)
}
if _, err := testEngine.Fetch(ctx, r, "undated.tgz", p); err != nil {
t.Errorf("seen 48h ago with 1 day quarantine: %v", err)
}
}
func TestQuarantineSkipsMutable(t *testing.T) {
requireStack(t)
r := quarantineRemote(t, "q-mutable", true)
if _, err := testEngine.Fetch(context.Background(), r, "pkg", prov(t, models.PackageNPM)); err != nil {
t.Errorf("mutable index blocked: %v", err)
}
}
func TestQuarantineDisabled(t *testing.T) {
requireStack(t)
r := quarantineRemote(t, "q-off", false)
if _, err := testEngine.Fetch(context.Background(), r, "fresh.tgz", prov(t, models.PackageNPM)); err != nil {
t.Errorf("disabled quarantine blocked: %v", err)
}
}
func TestQuarantineKeepsServingCached(t *testing.T) {
requireStack(t)
ctx := context.Background()
r := quarantineRemote(t, "q-cached", false)
p := prov(t, models.PackageNPM)
if _, err := testEngine.Fetch(ctx, r, "fresh.tgz", p); err != nil {
t.Fatal(err)
}
r.QuarantineEnabled = true
expire(t, r.Name, "fresh.tgz")
if _, err := testEngine.Fetch(ctx, r, "fresh.tgz", p); err != nil {
t.Errorf("artifact cached before quarantine refused on refetch: %v", err)
}
}
func TestQuarantineCapsFutureLastModified(t *testing.T) {
requireStack(t)
r := quarantineRemote(t, "q-future", true)
_, err := testEngine.Fetch(context.Background(), r, "future.tgz", prov(t, models.PackageNPM))
pe := wantQuarantined(t, err)
if d := time.Until(pe.Available); d < 71*time.Hour || d > 72*time.Hour {
t.Errorf("available in %s, want ~72h", d)
}
}
func TestQuarantineRedisErrorUnavailable(t *testing.T) {
requireStack(t)
ctx := context.Background()
r := quarantineRemote(t, "q-redis-down", true)
r.StaleOnError = true
down := must(cache.NewRedis(testRedis))
_ = down.Close()
eng := NewEngine(testDB, down, testEngine.store)
_, err := eng.Fetch(ctx, r, "undated.tgz", prov(t, models.PackageNPM))
var pe *ProxyError
if !errors.As(err, &pe) || pe.Status != http.StatusServiceUnavailable || pe.RetryAfter == 0 || Quarantined(err) {
t.Fatalf("want 503 with Retry-After, got %#v", err)
}
if a, _ := testDB.GetArtifact(ctx, r.Name, "undated.tgz"); a != nil {
t.Error("artifact cached while quarantine check unavailable")
}
}
func TestQuarantineStaleOnErrorServesNothingUncached(t *testing.T) {
requireStack(t)
ctx := context.Background()
r := quarantineRemote(t, "q-stale", true)
r.StaleOnError = true
p := prov(t, models.PackageNPM)
_, err := testEngine.Fetch(ctx, r, "fresh.tgz", p)
_ = wantQuarantined(t, err)
r.BaseURL = "http://127.0.0.1:1"
if _, err := testEngine.Fetch(ctx, r, "fresh.tgz", p); err == nil {
t.Error("stale_on_error served an uncached quarantined path")
}
if a, _ := testDB.GetArtifact(ctx, r.Name, "fresh.tgz"); a != nil {
t.Error("quarantined artifact was cached")
}
}
+96
View File
@@ -1,6 +1,7 @@
package server
import (
"encoding/base64"
"io"
"net/http"
"net/http/httptest"
@@ -78,3 +79,98 @@ func TestServerOIDCWriteAuth(t *testing.T) {
expect(204, "DELETE", "/api/v2/virtuals/srv-oidc-v", "", good)
expect(204, "DELETE", "/api/v2/remotes/srv-oidc", "", good)
}
func TestServerAuthUploads(t *testing.T) {
requireStack(t)
iss := testsupport.NewOIDCIssuer()
defer iss.Close()
cfg := *testSrv.cfg
cfg.OIDCIssuer = iss.URL
cfg.OIDCAudiences = []string{"artifactapi"}
cfg.AuthUploads = true
s := *testSrv
s.cfg = &cfg
ts := httptest.NewServer(s.routes())
defer ts.Close()
good := iss.Token(nil, "artifactapi", "ci", time.Hour)
do := func(method, path, body, authz string) int {
t.Helper()
rq, _ := http.NewRequest(method, ts.URL+path, strings.NewReader(body))
rq.Header.Set("Content-Type", "application/json")
if authz != "" {
rq.Header.Set("Authorization", authz)
}
resp, err := http.DefaultClient.Do(rq)
if err != nil {
t.Fatalf("%s %s: %v", method, path, err)
}
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
return resp.StatusCode
}
bearer := "Bearer " + good
for _, typ := range []string{"generic", "docker", "npm", "gems"} {
body := `{"name":"srv-up-` + typ + `","package_type":"` + typ + `","repo_type":"local"}`
if got := do("POST", "/api/v2/remotes", body, bearer); got != 201 {
t.Fatalf("create %s: status %d", typ, got)
}
}
writes := []struct{ method, path, body string }{
{"PUT", "/api/v2/remotes/srv-up-generic/files/a.bin", "payload"},
{"DELETE", "/api/v2/remotes/srv-up-generic/files/a.bin", ""},
{"DELETE", "/api/v2/remotes/srv-up-generic/objects/a.bin", ""},
{"DELETE", "/api/v2/locals/srv-up-generic/objects/a.bin", ""},
{"PUT", "/api/v1/local/srv-up-npm/pkg", "{}"},
{"POST", "/api/v1/local/srv-up-gems/api/v1/gems", "x"},
{"DELETE", "/api/v1/local/srv-up-gems/api/v1/gems/yank", ""},
{"POST", "/v2/srv-up-docker/img/blobs/uploads/", ""},
{"PATCH", "/v2/srv-up-docker/img/blobs/uploads/x", ""},
{"PUT", "/v2/srv-up-docker/img/manifests/t", "{}"},
{"DELETE", "/v2/srv-up-docker/img/manifests/t", ""},
}
for _, w := range writes {
if got := do(w.method, w.path, w.body, ""); got != 401 {
t.Errorf("anonymous %s %s: status %d, want 401", w.method, w.path, got)
}
}
if got := do("PUT", "/api/v2/remotes/srv-up-generic/files/a.bin", "payload", bearer); got != 201 {
t.Errorf("bearer upload: status %d", got)
}
if got := do("PUT", "/api/v2/remotes/srv-up-generic/files/b.bin", "payload", basic(good)); got != 201 {
t.Errorf("basic upload: status %d", got)
}
if got := do("POST", "/v2/srv-up-docker/img/blobs/uploads/", "", basic(good)); got != 202 {
t.Errorf("basic docker upload: status %d", got)
}
expectOpen := func(method, path string) {
t.Helper()
if got := do(method, path, "", ""); got >= 400 {
t.Errorf("anonymous %s %s: status %d", method, path, got)
}
}
expectOpen("GET", "/api/v2/remotes/srv-up-generic/files/a.bin")
expectOpen("GET", "/api/v2/remotes/srv-up-generic/objects")
expectOpen("GET", "/api/v1/local/srv-up-generic/a.bin")
expectOpen("GET", "/v2/token")
if got := do("GET", "/v2/", "", ""); got != 401 {
t.Errorf("anonymous ping: status %d, want 401 challenge", got)
}
if got := do("POST", "/api/v2/probe", `{}`, ""); got == 401 {
t.Error("probe gated")
}
if got := do("DELETE", "/api/v2/remotes/srv-up-generic/files/a.bin", "", bearer); got >= 400 {
t.Errorf("bearer delete: status %d", got)
}
for _, typ := range []string{"generic", "docker", "npm", "gems"} {
do("DELETE", "/api/v2/remotes/srv-up-"+typ, "", bearer)
}
}
func basic(tok string) string {
return "Basic " + base64.StdEncoding.EncodeToString([]byte("ci:"+tok))
}
+17 -6
View File
@@ -168,8 +168,18 @@ func (s *Server) routes() chi.Router {
proxyHandler := v1.NewProxyHandler(s.engine, s.virtEngine, s.db, s.store, s.localHandler)
s.virtEngine.NPMLocal = proxyHandler.LocalNPMPackument
s.virtEngine.GemsLocal = proxyHandler.LocalGemsIndex
r.Mount("/api/v1", proxyHandler.Routes())
r.Mount("/v2", proxyHandler.DockerV2Routes())
s.virtEngine.AnsibleLocal = proxyHandler.LocalAnsible
writeAuth := auth.NewBearerOIDC(s.cfg.OIDCIssuer, s.cfg.OIDCAudiences)
uploadAuth := passthrough
dockerV2 := http.Handler(proxyHandler.DockerV2Routes())
if s.cfg.AuthUploads {
uploadAuth = writeAuth.RequireWrites
dockerV2 = writeAuth.RequireRegistryWrites(dockerV2, func(r *http.Request) string {
return v1.Scheme(r) + "://" + r.Host + "/v2/token"
})
}
r.Mount("/api/v1", uploadAuth(proxyHandler.Routes()))
r.Mount("/v2", dockerV2)
remotesHandler := v2.NewRemotesHandler(s.db, s.cache, map[models.PackageType]v2.Primer{
models.PackageGitHubRPM: s.syncer,
@@ -181,7 +191,6 @@ func (s *Server) routes() chi.Router {
statsHandler := v2.NewStatsHandler(s.db)
eventsHandler := v2.NewEventsHandler()
probeHandler := v2.NewProbeHandler(s.engine, s.db)
writeAuth := auth.NewBearerOIDC(s.cfg.OIDCIssuer, s.cfg.OIDCAudiences)
r.Route("/api/v2", func(r chi.Router) {
r.Mount("/remotes", writeAuth.RequireWrites(remotesHandler.Routes()))
@@ -191,19 +200,19 @@ func (s *Server) routes() chi.Router {
r.Mount("/events", eventsHandler.Routes())
r.Mount("/probe", probeHandler.Routes())
r.Route("/remotes/{name}/objects", func(r chi.Router) {
r.With(uploadAuth).Route("/remotes/{name}/objects", func(r chi.Router) {
objHandler := v2.NewObjectsHandler(s.db)
r.Get("/", objHandler.Routes(s.engine).ServeHTTP)
r.Delete("/*", objHandler.Routes(s.engine).ServeHTTP)
})
r.Route("/locals/{name}/objects", func(r chi.Router) {
r.With(uploadAuth).Route("/locals/{name}/objects", func(r chi.Router) {
objHandler := v2.NewObjectsHandler(s.db)
r.Get("/", objHandler.LocalRoutes().ServeHTTP)
r.Delete("/*", objHandler.LocalRoutes().ServeHTTP)
})
r.Route("/remotes/{name}/files", func(r chi.Router) {
r.With(uploadAuth).Route("/remotes/{name}/files", func(r chi.Router) {
r.Put("/*", s.localHandler.Routes().ServeHTTP)
r.Get("/*", s.localHandler.Routes().ServeHTTP)
r.Delete("/*", s.localHandler.Routes().ServeHTTP)
@@ -213,6 +222,8 @@ func (s *Server) routes() chi.Router {
return r
}
func passthrough(h http.Handler) http.Handler { return h }
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)

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