diff --git a/PLAN.md b/PLAN.md index f7bd97c..cd490a2 100644 --- a/PLAN.md +++ b/PLAN.md @@ -672,7 +672,7 @@ func TestMain(m *testing.M) { ```hcl provider "artifactapi" { - endpoint = "https://artifactapi.k8s.syd1.au.unkin.net" + endpoint = "https://artifactapi.example.com" } resource "artifactapi_remote" "terraform_registry" { diff --git a/README.md b/README.md index 46a9081..21e173f 100644 --- a/README.md +++ b/README.md @@ -20,99 +20,27 @@ API: `http://localhost:8000` | Frontend: `http://localhost:5173` ## Package Types -| Type | Mutable (auto-detected) | Immutable (auto-detected) | -|---|---|---| -| `generic` | nothing | everything | -| `docker` | tag manifests, `/tags/list` | blobs, digest manifests | -| `helm` | `index.yaml` | `.tgz` charts | -| `pypi` | `simple/*` index pages | `.whl`, `.tar.gz` | -| `npm` | package metadata | `.tgz` tarballs | -| `rpm` | `repomd.xml`, `repodata/*` | `.rpm` | -| `alpine` | `APKINDEX.tar.gz` | `.apk` | -| `puppet` | `v3/modules/*`, `v3/releases*` | `.tar.gz` | -| `terraform` | `*/versions` | `*/download/*/*` | -| `goproxy` | `@v/list`, `@latest` | `.info`, `.mod`, `.zip` | -| `cargo` | sparse index files (`config.json` synthesized) | `crates/*/*.crate` | -| `ansible` | Galaxy `api/` JSON (links rewritten, `limit`/`offset` forwarded) | `artifacts/*.tar.gz` | -| `nuget` | `index.json` (service index), registration and flat container indexes | `.nupkg`, `.nuspec` | -| `github_rpm` | `repodata/*` (synthesized) | `.rpm` (redirected) | - Providers classify paths automatically. Users only configure what to proxy and TTLs. -### `github_rpm` — GitHub releases as a yum repo (metadata-only, no precache) +Classification precedence, TTLs and eviction for every remote: [docs/common/caching.md](docs/common/caching.md). -A `github_rpm` remote turns a GitHub repo's **releases** into a real `dnf`/`yum` -repository without ever caching the packages. It scans releases for `.rpm` -assets, derives each package's metadata (NEVRA, requires/provides/conflicts/ -obsoletes, files, checksum) and **synthesizes `repodata/` on the fly**. Package -metadata comes from a **ranged GET of just the RPM header** (the header sits at -the front of the file, so the whole package is never downloaded); the sha256 -checksum comes from the GitHub asset `digest` when present, else a one-time -lazy stream. Derived metadata is cached (keyed by asset) so repodata generation -is served from primed DB rows, never a cold on-demand derive. +Each type's paths, Terraform examples and local/virtual behaviour are in `docs//`: -Each package's `` points back at the remote, which **302-redirects** -the download to the `releases_remote` — an existing generic `github.com` remote -that streams the actual bytes. `dnf` follows the redirect transparently. - -#### Background syncer - -A single process-wide **background syncer** keeps every `github_rpm` remote's -derived metadata current off the client request path: - -- **Prime on create.** Creating a `github_rpm` remote enqueues a background prime - scan, so its metadata is derived right away without blocking the create call. - 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. -- **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 - responses against the rate limit, so an unchanged repo is nearly free — this is - the main lever keeping GitHub traffic low. -- **Global rate limit.** Every GitHub call (releases list + each ranged asset - header GET) passes through a single token-bucket limiter **shared across all - remotes**, so GitHub is never hammered. Configure a token (`password`) on the - remote for the higher authenticated rate limit (~5000/hr vs ~60/hr - unauthenticated). -- **Multi-replica coordination.** State is shared through the database. Before a - 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. - -```hcl -# Backend that serves the actual .rpm bytes from github.com. -resource "artifactapi_remote_generic" "github" { - name = "github" - base_url = "https://github.com" - patterns = [ - "acme/tools/releases/download/.*\\.rpm$", # allowlist the repo's release assets - ] -} - -resource "artifactapi_remote_github_rpm" "acme-tools" { - name = "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 - - # Optional: restrict which release assets become packages (regex on filename). - patterns = [".*\\.x86_64\\.rpm$", ".*\\.noarch\\.rpm$"] - - # Optional: a token for private repos / higher API rate limits. - # password = "ghp_..." -} -``` - -`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. +- `alpine`: [remote](docs/alpine/remote.md), [remote-github](docs/alpine/remote-github.md) +- `ansible`: [remote](docs/ansible/remote.md) +- `cargo`: [remote](docs/cargo/remote.md) +- `deb`: [remote](docs/deb/remote.md), [remote-github](docs/deb/remote-github.md) +- `docker`: [remote](docs/docker/remote.md), [local](docs/docker/local.md) +- `generic`: [remote](docs/generic/remote.md) +- `gems`: [remote](docs/gems/remote.md), [local](docs/gems/local.md), [virtual](docs/gems/virtual.md) +- `goproxy`: [remote](docs/goproxy/remote.md) +- `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) +- `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) ### GitHub authentication @@ -181,105 +109,9 @@ GITHUB_APP_PRIVATE_KEY_PATH=/etc/artifactapi/github-app.pem ## Terraform -Remotes and virtuals are managed by Terraform. Each package type has its own resource: +Remotes and virtuals are managed by Terraform. Each package type has its own resource; see `docs//` for examples. -```hcl -resource "artifactapi_remote_generic" "github" { - name = "github" - base_url = "https://github.com" - - immutable_ttl = 0 - mutable_ttl = 7200 - - patterns = [ - "ducaale/xh/.*/xh-.*-x86_64-unknown-linux-musl.tar.gz$", - "mikefarah/yq/.*/yq_linux_amd64$", - ] - - mutable_patterns = [ - ".*/archive/refs/heads/.*\\.tar\\.gz$", - ] -} - -resource "artifactapi_remote_docker" "dockerhub" { - name = "dockerhub" - base_url = "https://registry-1.docker.io" - - immutable_ttl = 0 - mutable_ttl = 300 - ban_tags_enabled = true - ban_tags = ["latest"] - - patterns = [ - "^library/postgres", - "^library/redis", - ] -} - -resource "artifactapi_remote_helm" "jetstack" { - name = "jetstack" - base_url = "https://charts.jetstack.io" - - immutable_ttl = 0 - mutable_ttl = 3600 -} - -resource "artifactapi_virtual" "helm" { - name = "helm" - package_type = "helm" - members = [artifactapi_remote_helm.jetstack.name] -} -``` - -Provider: [terraform-provider-artifactapi](../terraform-provider-artifactapi) - -### Serving providers as a registry - -A local `terraform` repo is a real provider registry: upload -`terraform-provider-{type}_{version}_{os}_{arch}.zip` files under -`{namespace}/{type}/`, and Terraform installs them from a bare source address — -no `.terraformrc` mirror config: - -```hcl -terraform { - required_providers { - artifactapi = { - source = "artifactapi.k8s.syd1.au.unkin.net//" - version = "0.1.2" - } - } -} -``` - -The Terraform *namespace* segment is the artifactapi repo name; the provider is -matched by *type*. The registry serves service discovery -(`/.well-known/terraform.json`), the `providers.v1` version/download endpoints, -and a GPG-signed `SHA256SUMS` per the provider registry protocol. - -Signing needs a GPG key. By default artifactapi generates one on first start and -stores it in the database (`signing_keys` table), so every replica shares it and -there's nothing to provision. To bring your own key instead, point -`TF_SIGNING_KEY_PATH` at an armored private key (optionally -`TF_SIGNING_KEY_PASSPHRASE`), which takes precedence over the generated one. -`TF_PROVIDER_PROTOCOLS` (default `5.0,6.0`) sets the advertised plugin protocols. - -### Local docker registry - -A local `docker` repo is a real container registry, not a mirror: it serves the -Docker Registry HTTP API V2 for both push and pull, so any client (`docker`, -`podman`, `skopeo`, `buildah`) can use it directly. - -```sh -docker tag myapp:latest artifactapi.k8s.syd1.au.unkin.net/docker-internal/myapp:latest -docker push artifactapi.k8s.syd1.au.unkin.net/docker-internal/myapp:latest -docker pull artifactapi.k8s.syd1.au.unkin.net/docker-internal/myapp:latest -``` - -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 -unreferenced); tags are mutable references and re-pushing a tag moves it. Blob -uploads support both the monolithic and chunked (`POST`/`PATCH`/`PUT`) flows. +Provider: [terraform-provider-artifactapi](https://git.unkin.net/unkin/terraform-provider-artifactapi) ## Access Control @@ -299,6 +131,8 @@ No patterns + no blocklist = open proxy. Provider handles mutability classificat ``` GET /api/v1/remote/{name}/{path} Proxy/cache artifact GET /api/v1/virtual/{name}/{path} Virtual repo (merged index) +PUT /api/v1/local/{name}/{package} npm publish (local npm repos) +POST/DELETE /api/v1/local/{name}/api/v1/gems[/yank] gem push / yank (local gems repos) GET /v2/{name}/{path} Docker Registry v2 ``` diff --git a/docs/alpine/remote-github.md b/docs/alpine/remote-github.md new file mode 100644 index 0000000..e9e473b --- /dev/null +++ b/docs/alpine/remote-github.md @@ -0,0 +1,7 @@ +# `github_alpine` remote + +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) | diff --git a/docs/alpine/remote.md b/docs/alpine/remote.md new file mode 100644 index 0000000..f415b81 --- /dev/null +++ b/docs/alpine/remote.md @@ -0,0 +1,7 @@ +# `alpine` remote + +Shared classification, TTL and eviction rules: [caching](../common/caching.md). + +| Type | Mutable (auto-detected) | Immutable (auto-detected) | +|---|---|---| +| `alpine` | `APKINDEX.tar.gz` | `.apk` | diff --git a/docs/ansible/remote.md b/docs/ansible/remote.md new file mode 100644 index 0000000..c6ea0b5 --- /dev/null +++ b/docs/ansible/remote.md @@ -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/ +``` diff --git a/docs/cargo/remote.md b/docs/cargo/remote.md new file mode 100644 index 0000000..d7bec9e --- /dev/null +++ b/docs/cargo/remote.md @@ -0,0 +1,7 @@ +# `cargo` remote + +Shared classification, TTL and eviction rules: [caching](../common/caching.md). + +| Type | Mutable (auto-detected) | Immutable (auto-detected) | +|---|---|---| +| `cargo` | sparse index files (`config.json` synthesized) | `crates/*/*.crate` | diff --git a/docs/common/caching.md b/docs/common/caching.md new file mode 100644 index 0000000..20fd733 --- /dev/null +++ b/docs/common/caching.md @@ -0,0 +1,101 @@ +# Caching and classification (all remotes) + +Every proxied request on a remote is classified as **denied**, **immutable** or +**mutable**. The class picks the storage path and the freshness TTL. + +## Fields + +| Field | Meaning | +|---|---| +| `patterns` | Allowlist of regexes. Empty allows every path; otherwise a non-matching path is denied (403). | +| `blocklist` | Regexes; a match is denied (403). | +| `immutable_patterns` | Regexes forcing a path immutable. | +| `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. | +| `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. | +| `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 + +The first rule that matches wins: + +1. `blocklist` matches: **denied** +2. `patterns` is non-empty and nothing matches: **denied** +3. `immutable_patterns` matches: **immutable** +4. `mutable_patterns` matches: **mutable** +5. the provider's built-in `Classify` says mutable: **mutable** +6. anything else: **immutable** + +So `blocklist` beats the allowlist, and `immutable_patterns` beats +`mutable_patterns`. Either user list overrides the built-in rules. `GET` and +`HEAD` both apply this order. + +## Matching rules + +- Regexes are Go `regexp`, matched **unanchored** (`MatchString`). Add `^` or `$` yourself when you need them. +- They match the path after `/api/v1/remote//`, without a leading slash, e.g. `9/BaseOS/x86_64/os/repodata/repomd.xml`. +- The query string is not part of the path. It is dropped and not forwarded upstream. +- `create` and `update` reject an invalid regex in `patterns`, `blocklist`, `mutable_patterns`, `immutable_patterns` or `ban_tags` with a 400. Only invalid regexes already stored before that check are skipped at match time. + +## Lifecycles + +| | Immutable | Mutable | +|---|---|---| +| Stored as | content-addressed blob (sha256), streamed | per-path index object, buffered and rewritten by the provider | +| Fresh for | `immutable_ttl` (`0` = forever) | `mutable_ttl` | +| After expiry | refetched from upstream | revalidated with `If-None-Match` when `check_mutable` is set and an ETag is stored, else refetched | +| Upstream down | `stale_on_error` serves the stored copy and re-arms the TTL; an open circuit breaker always serves stale if a copy exists, else 503 | same | + +Concurrent misses on one path share a fetch lock. Waiters poll the store for +up to 5s instead of all going upstream. + +## Eviction + +```sh +curl -X DELETE https://artifactapi.example.com/api/v2/remotes//objects/ +curl -X DELETE https://artifactapi.example.com/api/v2/remotes//objects//* +``` + +- Evict drops the artifact row, the index object and the Redis TTL and ETag keys, so the next request goes upstream whatever the class or TTL. +- Deleting a remote drops its Redis TTL and ETag keys, so a re-created remote of the same name starts cold. +- A single-path evict takes the fetch lock first, so an in-flight fetch can't re-cache the path. It returns 503 if the lock stays busy. +- A wildcard must end in `/*` and takes no lock. +- Returns 204, or 404 for an unknown remote. + +## Examples + +Bodies for `POST https://artifactapi.example.com/api/v2/remotes`. + +GitHub release assets through a `generic` remote, allowlisted: + +```json +{"name": "github", "package_type": "generic", "base_url": "https://github.com", + "immutable_ttl": 0, "mutable_ttl": 7200, + "patterns": ["^mikefarah/yq/releases/download/.*/yq_linux_amd64$"]} +``` + +rpm remote: `repodata/*` and `repomd.xml` are mutable by default, so only the TTLs are needed: + +```json +{"name": "almalinux", "package_type": "rpm", "base_url": "https://repo.almalinux.org/almalinux", + "immutable_ttl": 0, "mutable_ttl": 3600, "check_mutable": true, "stale_on_error": true} +``` + +Force a moving path mutable, e.g. branch tarballs on `generic`: + +```json +{"mutable_patterns": ["/archive/refs/heads/.*\\.tar\\.gz$"], "mutable_ttl": 600} +``` + +## 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), +[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). diff --git a/docs/deb/remote-github.md b/docs/deb/remote-github.md new file mode 100644 index 0000000..99d9f11 --- /dev/null +++ b/docs/deb/remote-github.md @@ -0,0 +1,7 @@ +# `github_deb` remote + +Shared classification, TTL and eviction rules: [caching](../common/caching.md). + +| Type | Mutable (auto-detected) | Immutable (auto-detected) | +|---|---|---| +| `github_deb` | `Packages*`, `Release*` (synthesized) | `.deb` (redirected) | diff --git a/docs/deb/remote.md b/docs/deb/remote.md new file mode 100644 index 0000000..865d949 --- /dev/null +++ b/docs/deb/remote.md @@ -0,0 +1,7 @@ +# `deb` remote + +Shared classification, TTL and eviction rules: [caching](../common/caching.md). + +| Type | Mutable (auto-detected) | Immutable (auto-detected) | +|---|---|---| +| `deb` | `Packages*`, `Sources*`, `Release`, `InRelease`, `Release.gpg`, `Contents-*` | `.deb` | diff --git a/docs/docker/local.md b/docs/docker/local.md new file mode 100644 index 0000000..54d7a55 --- /dev/null +++ b/docs/docker/local.md @@ -0,0 +1,17 @@ +# Local docker registry + +A local `docker` repo is a real container registry, not a mirror: it serves the +Docker Registry HTTP API V2 for both push and pull, so any client (`docker`, +`podman`, `skopeo`, `buildah`) can use it directly. + +```sh +docker tag myapp:latest artifactapi.example.com/docker-internal/myapp:latest +docker push artifactapi.example.com/docker-internal/myapp:latest +docker pull artifactapi.example.com/docker-internal/myapp:latest +``` + +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 +unreferenced); tags are mutable references and re-pushing a tag moves it. Blob +uploads support both the monolithic and chunked (`POST`/`PATCH`/`PUT`) flows. diff --git a/docs/docker/remote.md b/docs/docker/remote.md new file mode 100644 index 0000000..c8a79f1 --- /dev/null +++ b/docs/docker/remote.md @@ -0,0 +1,26 @@ +# `docker` remote + +Shared classification, TTL and eviction rules: [caching](../common/caching.md). + +| Type | Mutable (auto-detected) | Immutable (auto-detected) | +|---|---|---| +| `docker` | tag manifests, `/tags/list` | blobs, digest manifests | + +## Terraform + +```hcl +resource "artifactapi_remote_docker" "dockerhub" { + name = "dockerhub" + base_url = "https://registry-1.docker.io" + + immutable_ttl = 0 + mutable_ttl = 300 + ban_tags_enabled = true + ban_tags = ["latest"] + + patterns = [ + "^library/postgres", + "^library/redis", + ] +} +``` diff --git a/docs/gems/local.md b/docs/gems/local.md new file mode 100644 index 0000000..caf720c --- /dev/null +++ b/docs/gems/local.md @@ -0,0 +1,24 @@ +# Local gems repository + +A local `gems` repo is a RubyGems source: `gem push`, `gem yank`, `gem install` +and `bundle install` work against `/api/v1/local/`. `gem push` needs an +API key; any value works. + +```bash +GEM_HOST_API_KEY=unused gem push --host https://artifactapi.example.com/api/v1/local/ mygem-1.0.0.gem +gem install mygem --source https://artifactapi.example.com/api/v1/local// +gem yank mygem -v 1.0.0 --host https://artifactapi.example.com/api/v1/local/ +``` + +```ruby +# Gemfile +source "https://artifactapi.example.com/api/v1/local//" +gem "mygem" +``` + +The repo serves the compact index (`versions`, `info/`, `names`), `gems/*.gem` +and `quick/Marshal.4.8/*.gemspec.rz`. The legacy `specs.4.8.gz` indexes are not +served; current gem and bundler clients use the compact index. + +Versions are immutable: pushing an existing name-version-platform is rejected +with 409. A yanked version disappears from the index and its files are removed. diff --git a/docs/gems/remote.md b/docs/gems/remote.md new file mode 100644 index 0000000..9f4707b --- /dev/null +++ b/docs/gems/remote.md @@ -0,0 +1,7 @@ +# `gems` remote + +Shared classification, TTL and eviction rules: [caching](../common/caching.md). + +| Type | Mutable (auto-detected) | Immutable (auto-detected) | +|---|---|---| +| `gems` | compact index (`versions`, `info/*`, `names`), `*specs.4.8.gz` | `gems/*.gem`, `quick/Marshal.4.8/*.gemspec.rz` | diff --git a/docs/gems/virtual.md b/docs/gems/virtual.md new file mode 100644 index 0000000..e894113 --- /dev/null +++ b/docs/gems/virtual.md @@ -0,0 +1,28 @@ +# `gems` virtual + +A gems virtual serves one RubyGems source over local and remote gems members: +`gem install` and `bundle install` work against `/api/v1/virtual//`. + +- `info/` is the union of member lines; on a duplicate version-platform + the earlier member wins. A gem only one member has is served verbatim. +- `versions` regenerates the line (and md5) of every gem several members list + from its merged `info`; other gems' lines pass through. `names` is the union. +- `gems/*.gem` and `quick/Marshal.4.8/*.gemspec.rz` 302 to the member whose + `info` line won. +- A member error fails the request with 502; a member without the gem is + skipped. Merges are cached for 60s. + +```hcl +resource "artifactapi_virtual" "gems" { + name = "gems" + package_type = "gems" + members = [artifactapi_local_gems.internal.name, artifactapi_remote_gems.rubygems.name] +} +``` + +```ruby +# Gemfile +source "https://artifactapi.example.com/api/v1/virtual/gems/" +``` + +Push to the local member directly; the virtual is read-only. diff --git a/docs/generic/remote.md b/docs/generic/remote.md new file mode 100644 index 0000000..7fac792 --- /dev/null +++ b/docs/generic/remote.md @@ -0,0 +1,28 @@ +# `generic` remote + +Shared classification, TTL and eviction rules: [caching](../common/caching.md). + +| Type | Mutable (auto-detected) | Immutable (auto-detected) | +|---|---|---| +| `generic` | nothing | everything | + +## Terraform + +```hcl +resource "artifactapi_remote_generic" "github" { + name = "github" + base_url = "https://github.com" + + immutable_ttl = 0 + mutable_ttl = 7200 + + patterns = [ + "ducaale/xh/.*/xh-.*-x86_64-unknown-linux-musl.tar.gz$", + "mikefarah/yq/.*/yq_linux_amd64$", + ] + + mutable_patterns = [ + ".*/archive/refs/heads/.*\\.tar\\.gz$", + ] +} +``` diff --git a/docs/goproxy/remote.md b/docs/goproxy/remote.md new file mode 100644 index 0000000..52d1c49 --- /dev/null +++ b/docs/goproxy/remote.md @@ -0,0 +1,7 @@ +# `goproxy` remote + +Shared classification, TTL and eviction rules: [caching](../common/caching.md). + +| Type | Mutable (auto-detected) | Immutable (auto-detected) | +|---|---|---| +| `goproxy` | `@v/list`, `@latest` | `.info`, `.mod`, `.zip` | diff --git a/docs/helm/local.md b/docs/helm/local.md new file mode 100644 index 0000000..898ff88 --- /dev/null +++ b/docs/helm/local.md @@ -0,0 +1,15 @@ +# Local helm repository + +A local `helm` repo is a real chart repository: `PUT` a packaged chart to +`/api/v2/remotes//files/-.tgz` and `index.yaml` is +generated from each chart's `Chart.yaml` (with sha256 digests) at +`/api/v1/local//index.yaml`; a file not named after its `Chart.yaml` +name and version is stored but left out of the index. Add the local as a +member of a helm virtual to serve it alongside remotes. + +```bash +helm package ./mychart +curl -fsSL --upload-file mychart-0.1.0.tgz \ + https://artifactapi.example.com/api/v2/remotes//files/mychart-0.1.0.tgz +helm repo add https://artifactapi.example.com/api/v1/local/ +``` diff --git a/docs/helm/remote.md b/docs/helm/remote.md new file mode 100644 index 0000000..b51e028 --- /dev/null +++ b/docs/helm/remote.md @@ -0,0 +1,19 @@ +# `helm` remote + +Shared classification, TTL and eviction rules: [caching](../common/caching.md). + +| Type | Mutable (auto-detected) | Immutable (auto-detected) | +|---|---|---| +| `helm` | `index.yaml` | `.tgz` charts | + +## Terraform + +```hcl +resource "artifactapi_remote_helm" "jetstack" { + name = "jetstack" + base_url = "https://charts.jetstack.io" + + immutable_ttl = 0 + mutable_ttl = 3600 +} +``` diff --git a/docs/helm/virtual.md b/docs/helm/virtual.md new file mode 100644 index 0000000..91a56b0 --- /dev/null +++ b/docs/helm/virtual.md @@ -0,0 +1,9 @@ +# `helm` virtual + +```hcl +resource "artifactapi_virtual" "helm" { + name = "helm" + package_type = "helm" + members = [artifactapi_remote_helm.jetstack.name] +} +``` diff --git a/docs/npm/local.md b/docs/npm/local.md new file mode 100644 index 0000000..798a48b --- /dev/null +++ b/docs/npm/local.md @@ -0,0 +1,14 @@ +# Local npm registry + +A local `npm` repo is a real npm registry: `npm publish` (scoped and unscoped) +and `npm install` work against `/api/v1/local//`. npm requires an +`_authToken` for the registry before it will publish; any value works. + +```ini +# .npmrc +registry=https://artifactapi.example.com/api/v1/local// +//artifactapi.example.com/api/v1/local//:_authToken=unused +``` + +Versions are immutable: republishing an existing version is rejected. The +dist-tags sent with a publish (`latest`, or `--tag`) move to that version. diff --git a/docs/npm/remote.md b/docs/npm/remote.md new file mode 100644 index 0000000..8559315 --- /dev/null +++ b/docs/npm/remote.md @@ -0,0 +1,7 @@ +# `npm` remote + +Shared classification, TTL and eviction rules: [caching](../common/caching.md). + +| Type | Mutable (auto-detected) | Immutable (auto-detected) | +|---|---|---| +| `npm` | package metadata | `.tgz` tarballs | diff --git a/docs/npm/virtual.md b/docs/npm/virtual.md new file mode 100644 index 0000000..19deb9a --- /dev/null +++ b/docs/npm/virtual.md @@ -0,0 +1,30 @@ +# `npm` virtual + +An npm virtual serves one registry over local and remote npm members: +`npm install` and `npm view` work against `/api/v1/virtual//`. + +- Versions are the union across members; on a duplicate version the earlier + member wins. +- Each dist-tag comes from the first member defining it, and is dropped if it + names a version missing from the merged set. +- Tarball URLs point at the virtual (`/`), which 302s to the + owning member. +- Full and abbreviated (`application/vnd.npm.install-v1+json`) packuments are + both served. +- A member error fails the request with 502; a member without the package is + skipped. Merges are cached for 60s per package. + +```hcl +resource "artifactapi_virtual" "npm" { + name = "npm" + package_type = "npm" + members = [artifactapi_local_npm.internal.name, artifactapi_remote_npm.npmjs.name] +} +``` + +```ini +# .npmrc +registry=https://artifactapi.example.com/api/v1/virtual/npm/ +``` + +Publish to the local member directly; the virtual is read-only. diff --git a/docs/nuget/remote.md b/docs/nuget/remote.md new file mode 100644 index 0000000..7e6ed99 --- /dev/null +++ b/docs/nuget/remote.md @@ -0,0 +1,7 @@ +# `nuget` remote + +Shared classification, TTL and eviction rules: [caching](../common/caching.md). + +| Type | Mutable (auto-detected) | Immutable (auto-detected) | +|---|---|---| +| `nuget` | `index.json` (service index), registration and flat container indexes | `.nupkg`, `.snupkg`, `.nuspec`, `readme`, `icon` | diff --git a/docs/puppet/local.md b/docs/puppet/local.md new file mode 100644 index 0000000..c5258d0 --- /dev/null +++ b/docs/puppet/local.md @@ -0,0 +1,14 @@ +# Local puppet forge + +A local `puppet` repo is a Forge: `PUT` a module tarball to +`/api/v2/remotes//files/--.tar.gz` and the Forge +v3 API (`/v3/modules`, `/v3/releases`, `/v3/files`) is served from each +release's `metadata.json` at `/api/v1/local/`. Versions are immutable. + +```bash +puppet module build ./hello +curl -fsSL --upload-file pkg/unkin-hello-0.1.0.tar.gz \ + https://artifactapi.example.com/api/v2/remotes//files/unkin-hello-0.1.0.tar.gz +puppet module install unkin-hello \ + --module_repository https://artifactapi.example.com/api/v1/local/ +``` diff --git a/docs/puppet/remote.md b/docs/puppet/remote.md new file mode 100644 index 0000000..8c7435e --- /dev/null +++ b/docs/puppet/remote.md @@ -0,0 +1,7 @@ +# `puppet` remote + +Shared classification, TTL and eviction rules: [caching](../common/caching.md). + +| Type | Mutable (auto-detected) | Immutable (auto-detected) | +|---|---|---| +| `puppet` | `v3/modules/*`, `v3/releases*` | `.tar.gz` | diff --git a/docs/pypi/remote.md b/docs/pypi/remote.md new file mode 100644 index 0000000..0f5fe32 --- /dev/null +++ b/docs/pypi/remote.md @@ -0,0 +1,7 @@ +# `pypi` remote + +Shared classification, TTL and eviction rules: [caching](../common/caching.md). + +| Type | Mutable (auto-detected) | Immutable (auto-detected) | +|---|---|---| +| `pypi` | `simple/*` index pages | `.whl`, `.tar.gz` | diff --git a/docs/rpm/remote-github.md b/docs/rpm/remote-github.md new file mode 100644 index 0000000..f079b02 --- /dev/null +++ b/docs/rpm/remote-github.md @@ -0,0 +1,82 @@ +# `github_rpm` remote + +Shared classification, TTL and eviction rules: [caching](../common/caching.md). + +| Type | Mutable (auto-detected) | Immutable (auto-detected) | +|---|---|---| +| `github_rpm` | `repodata/*` (synthesized) | `.rpm` (redirected) | + +## GitHub releases as a yum repo (metadata-only, no precache) + +A `github_rpm` remote turns a GitHub repo's **releases** into a real `dnf`/`yum` +repository without ever caching the packages. It scans releases for `.rpm` +assets, derives each package's metadata (NEVRA, requires/provides/conflicts/ +obsoletes, files, checksum) and **synthesizes `repodata/` on the fly**. Package +metadata comes from a **ranged GET of just the RPM header** (the header sits at +the front of the file, so the whole package is never downloaded); the sha256 +checksum comes from the GitHub asset `digest` when present, else a one-time +lazy stream. Derived metadata is cached (keyed by asset) so repodata generation +is served from primed DB rows, never a cold on-demand derive. + +Each package's `` points back at the remote, which **302-redirects** +the download to the `releases_remote` — an existing generic `github.com` remote +that streams the actual bytes. `dnf` follows the redirect transparently. + +### Background syncer + +A single process-wide **background syncer** keeps every `github_rpm` remote's +derived metadata current off the client request path: + +- **Prime on create.** Creating a `github_rpm` remote enqueues a background prime + scan, so its metadata is derived right away without blocking the create call. + 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. +- **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 + responses against the rate limit, so an unchanged repo is nearly free — this is + the main lever keeping GitHub traffic low. +- **Global rate limit.** Every GitHub call (releases list + each ranged asset + header GET) passes through a single token-bucket limiter **shared across all + remotes**, so GitHub is never hammered. Configure a token (`password`) on the + remote for the higher authenticated rate limit (~5000/hr vs ~60/hr + unauthenticated). +- **Multi-replica coordination.** State is shared through the database. Before a + 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. + +```hcl +# Backend that serves the actual .rpm bytes from github.com. +resource "artifactapi_remote_generic" "github" { + name = "github" + base_url = "https://github.com" + patterns = [ + "acme/tools/releases/download/.*\\.rpm$", # allowlist the repo's release assets + ] +} + +resource "artifactapi_remote_github_rpm" "acme-tools" { + name = "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 + + # Optional: restrict which release assets become packages (regex on filename). + patterns = [".*\\.x86_64\\.rpm$", ".*\\.noarch\\.rpm$"] + + # Optional: a token for private repos / higher API rate limits. + # password = "ghp_..." +} +``` + +`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. diff --git a/docs/rpm/remote.md b/docs/rpm/remote.md new file mode 100644 index 0000000..f4a93b2 --- /dev/null +++ b/docs/rpm/remote.md @@ -0,0 +1,7 @@ +# `rpm` remote + +Shared classification, TTL and eviction rules: [caching](../common/caching.md). + +| Type | Mutable (auto-detected) | Immutable (auto-detected) | +|---|---|---| +| `rpm` | `repomd.xml`, `repodata/*` | `.rpm` | diff --git a/docs/terraform/local.md b/docs/terraform/local.md new file mode 100644 index 0000000..2b3e0d4 --- /dev/null +++ b/docs/terraform/local.md @@ -0,0 +1,29 @@ +# Serving providers as a registry + +A local `terraform` repo is a real provider registry: upload +`terraform-provider-{type}_{version}_{os}_{arch}.zip` files under +`{namespace}/{type}/`, and Terraform installs them from a bare source address — +no `.terraformrc` mirror config: + +```hcl +terraform { + required_providers { + artifactapi = { + source = "artifactapi.example.com//" + version = "0.1.2" + } + } +} +``` + +The Terraform *namespace* segment is the artifactapi repo name; the provider is +matched by *type*. The registry serves service discovery +(`/.well-known/terraform.json`), the `providers.v1` version/download endpoints, +and a GPG-signed `SHA256SUMS` per the provider registry protocol. + +Signing needs a GPG key. By default artifactapi generates one on first start and +stores it in the database (`signing_keys` table), so every replica shares it and +there's nothing to provision. To bring your own key instead, point +`TF_SIGNING_KEY_PATH` at an armored private key (optionally +`TF_SIGNING_KEY_PASSPHRASE`), which takes precedence over the generated one. +`TF_PROVIDER_PROTOCOLS` (default `5.0,6.0`) sets the advertised plugin protocols. diff --git a/docs/terraform/remote.md b/docs/terraform/remote.md new file mode 100644 index 0000000..d4fe58b --- /dev/null +++ b/docs/terraform/remote.md @@ -0,0 +1,7 @@ +# `terraform` remote + +Shared classification, TTL and eviction rules: [caching](../common/caching.md). + +| Type | Mutable (auto-detected) | Immutable (auto-detected) | +|---|---|---| +| `terraform` | `*/versions` | `*/download/*/*` | diff --git a/e2e-docker/README.md b/e2e-docker/README.md index cd7c07e..4cc17ef 100644 --- a/e2e-docker/README.md +++ b/e2e-docker/README.md @@ -22,14 +22,21 @@ already-running stack. ## Coverage - **Repository lifecycle** — add / change / delete for remote, local and virtual repos. -- **Caching** — one immutable artifact per remote package type (generic, docker, - helm, pypi, npm, rpm, alpine, puppet, terraform, goproxy, cargo, nuget, ansible) proxied through the - mock upstream: first fetch `X-Artifact-Source: remote`, second `cache`, bytes +- **Caching** — one immutable artifact per remote package type (one + `caching__test.go` case each) proxied through the mock upstream: first fetch `X-Artifact-Source: remote`, second `cache`, bytes verified against the origin fixture. -- **Local uploads** — generic (upload/download), pypi (wheel + generated `simple/` - index), rpm (real package + **automatic repodata** generation). +- **Local uploads** — puppet (Forge v3 API via real `r10k` and `puppet module install`), + generic (upload/download), pypi (wheel + generated `simple/` + index), rpm (real package + **automatic repodata** generation), npm (stock + `node:22` client: `npm publish` unscoped + scoped, `npm install` + `require()`, + second version, republish rejected), gems (stock `ruby:3.3`: `gem push`, + `gem install`, `bundle install` + `require`, republish rejected, `gem yank`, + yanked re-push rejected). - **Virtual repositories** — pypi simple-index merge and helm `index.yaml` merge - across two members. + 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). - **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, diff --git a/e2e-docker/caching_alpine_test.go b/e2e-docker/caching_alpine_test.go new file mode 100644 index 0000000..d970f44 --- /dev/null +++ b/e2e-docker/caching_alpine_test.go @@ -0,0 +1,7 @@ +//go:build dockere2e + +package e2edocker + +func init() { + cachingCases = append(cachingCases, cachingCase{"alpine", "alpine/x86_64/testpkg-1.0-r0.apk", "alpine/x86_64/testpkg-1.0-r0.apk"}) +} diff --git a/e2e-docker/caching_ansible_test.go b/e2e-docker/caching_ansible_test.go new file mode 100644 index 0000000..62c9d19 --- /dev/null +++ b/e2e-docker/caching_ansible_test.go @@ -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"}) +} diff --git a/e2e-docker/caching_cargo_test.go b/e2e-docker/caching_cargo_test.go new file mode 100644 index 0000000..e5d6e0d --- /dev/null +++ b/e2e-docker/caching_cargo_test.go @@ -0,0 +1,7 @@ +//go:build dockere2e + +package e2edocker + +func init() { + cachingCases = append(cachingCases, cachingCase{"cargo", "crates/mycrate/mycrate-1.0.0.crate", "crates/mycrate/mycrate-1.0.0.crate"}) +} diff --git a/e2e-docker/caching_docker_test.go b/e2e-docker/caching_docker_test.go new file mode 100644 index 0000000..87f980c --- /dev/null +++ b/e2e-docker/caching_docker_test.go @@ -0,0 +1,7 @@ +//go:build dockere2e + +package e2edocker + +func init() { + cachingCases = append(cachingCases, cachingCase{"docker", "library/testimg/blobs/blobdata", "v2/library/testimg/blobs/blobdata"}) +} diff --git a/e2e-docker/caching_gems_test.go b/e2e-docker/caching_gems_test.go new file mode 100644 index 0000000..9ef2c8b --- /dev/null +++ b/e2e-docker/caching_gems_test.go @@ -0,0 +1,7 @@ +//go:build dockere2e + +package e2edocker + +func init() { + cachingCases = append(cachingCases, cachingCase{"gems", "gems/mygem-1.0.0.gem", "gems/mygem-1.0.0.gem"}) +} diff --git a/e2e-docker/caching_generic_test.go b/e2e-docker/caching_generic_test.go new file mode 100644 index 0000000..ff4dc48 --- /dev/null +++ b/e2e-docker/caching_generic_test.go @@ -0,0 +1,7 @@ +//go:build dockere2e + +package e2edocker + +func init() { + cachingCases = append(cachingCases, cachingCase{"generic", "blobs/hello.bin", "blobs/hello.bin"}) +} diff --git a/e2e-docker/caching_goproxy_test.go b/e2e-docker/caching_goproxy_test.go new file mode 100644 index 0000000..3482bae --- /dev/null +++ b/e2e-docker/caching_goproxy_test.go @@ -0,0 +1,7 @@ +//go:build dockere2e + +package e2edocker + +func init() { + cachingCases = append(cachingCases, cachingCase{"goproxy", "goproxy/example.com/mod/@v/v1.0.0.zip", "goproxy/example.com/mod/@v/v1.0.0.zip"}) +} diff --git a/e2e-docker/caching_helm_test.go b/e2e-docker/caching_helm_test.go new file mode 100644 index 0000000..548f1f2 --- /dev/null +++ b/e2e-docker/caching_helm_test.go @@ -0,0 +1,7 @@ +//go:build dockere2e + +package e2edocker + +func init() { + cachingCases = append(cachingCases, cachingCase{"helm", "charts/mychart-1.0.0.tgz", "charts/mychart-1.0.0.tgz"}) +} diff --git a/e2e-docker/caching_npm_test.go b/e2e-docker/caching_npm_test.go new file mode 100644 index 0000000..912fa85 --- /dev/null +++ b/e2e-docker/caching_npm_test.go @@ -0,0 +1,7 @@ +//go:build dockere2e + +package e2edocker + +func init() { + cachingCases = append(cachingCases, cachingCase{"npm", "mypkg/-/mypkg-1.0.0.tgz", "mypkg/-/mypkg-1.0.0.tgz"}) +} diff --git a/e2e-docker/caching_nuget_test.go b/e2e-docker/caching_nuget_test.go new file mode 100644 index 0000000..be8ee69 --- /dev/null +++ b/e2e-docker/caching_nuget_test.go @@ -0,0 +1,7 @@ +//go:build dockere2e + +package e2edocker + +func init() { + cachingCases = append(cachingCases, cachingCase{"nuget", "nuget/e2epkg/1.0.0/e2epkg.1.0.0.nupkg", "nuget/e2epkg/1.0.0/e2epkg.1.0.0.nupkg"}) +} diff --git a/e2e-docker/caching_puppet_test.go b/e2e-docker/caching_puppet_test.go new file mode 100644 index 0000000..74d2024 --- /dev/null +++ b/e2e-docker/caching_puppet_test.go @@ -0,0 +1,7 @@ +//go:build dockere2e + +package e2edocker + +func init() { + cachingCases = append(cachingCases, cachingCase{"puppet", "puppet-releases/author-mod-1.0.0.tar.gz", "puppet-releases/author-mod-1.0.0.tar.gz"}) +} diff --git a/e2e-docker/caching_pypi_test.go b/e2e-docker/caching_pypi_test.go new file mode 100644 index 0000000..3948f8e --- /dev/null +++ b/e2e-docker/caching_pypi_test.go @@ -0,0 +1,7 @@ +//go:build dockere2e + +package e2edocker + +func init() { + cachingCases = append(cachingCases, cachingCase{"pypi", "packages/foo-1.0-py3-none-any.whl", "packages/foo-1.0-py3-none-any.whl"}) +} diff --git a/e2e-docker/caching_rpm_test.go b/e2e-docker/caching_rpm_test.go new file mode 100644 index 0000000..d4ff226 --- /dev/null +++ b/e2e-docker/caching_rpm_test.go @@ -0,0 +1,7 @@ +//go:build dockere2e + +package e2edocker + +func init() { + cachingCases = append(cachingCases, cachingCase{"rpm", "rpmrepo/Packages/e2e-testpkg-1.0-1.noarch.rpm", "rpmrepo/Packages/e2e-testpkg-1.0-1.noarch.rpm"}) +} diff --git a/e2e-docker/caching_terraform_test.go b/e2e-docker/caching_terraform_test.go new file mode 100644 index 0000000..f280d76 --- /dev/null +++ b/e2e-docker/caching_terraform_test.go @@ -0,0 +1,7 @@ +//go:build dockere2e + +package e2edocker + +func init() { + cachingCases = append(cachingCases, cachingCase{"terraform", "hashicorp/aws/download/pkg.zip", "v1/providers/hashicorp/aws/download/pkg.zip"}) +} diff --git a/e2e-docker/caching_test.go b/e2e-docker/caching_test.go index ddf992a..603ecd1 100644 --- a/e2e-docker/caching_test.go +++ b/e2e-docker/caching_test.go @@ -9,34 +9,23 @@ import ( "testing" ) +type cachingCase struct { + pkgType string + // path is the request path under /api/v1/remote//. The provider + // derives the upstream URL from it (docker prepends /v2/, terraform + // prepends /v1/providers/), and the fixture lives at that resolved path. + path string + fixture string +} + +// cachingCases is filled by one caching__test.go file per package type. +var cachingCases []cachingCase + // TestCachingPerProvider proxies one immutable artifact for every remote // package type through the mock upstream and asserts: first fetch is served // from the remote, the second from cache, and the bytes match the origin. func TestCachingPerProvider(t *testing.T) { - cases := []struct { - pkgType string - // path is the request path under /api/v1/remote//. The provider - // derives the upstream URL from it (docker prepends /v2/, terraform - // prepends /v1/providers/), and the fixture lives at that resolved path. - path string - fixture string - }{ - {"generic", "blobs/hello.bin", "blobs/hello.bin"}, - {"npm", "mypkg/-/mypkg-1.0.0.tgz", "mypkg/-/mypkg-1.0.0.tgz"}, - {"helm", "charts/mychart-1.0.0.tgz", "charts/mychart-1.0.0.tgz"}, - {"pypi", "packages/foo-1.0-py3-none-any.whl", "packages/foo-1.0-py3-none-any.whl"}, - {"rpm", "rpmrepo/Packages/e2e-testpkg-1.0-1.noarch.rpm", "rpmrepo/Packages/e2e-testpkg-1.0-1.noarch.rpm"}, - {"alpine", "alpine/x86_64/testpkg-1.0-r0.apk", "alpine/x86_64/testpkg-1.0-r0.apk"}, - {"puppet", "puppet-releases/author-mod-1.0.0.tar.gz", "puppet-releases/author-mod-1.0.0.tar.gz"}, - {"goproxy", "goproxy/example.com/mod/@v/v1.0.0.zip", "goproxy/example.com/mod/@v/v1.0.0.zip"}, - {"cargo", "crates/mycrate/mycrate-1.0.0.crate", "crates/mycrate/mycrate-1.0.0.crate"}, - {"ansible", "galaxy/artifacts/ns-col-1.0.0.tar.gz", "galaxy/artifacts/ns-col-1.0.0.tar.gz"}, - {"nuget", "nuget/e2epkg/1.0.0/e2epkg.1.0.0.nupkg", "nuget/e2epkg/1.0.0/e2epkg.1.0.0.nupkg"}, - {"terraform", "hashicorp/aws/download/pkg.zip", "v1/providers/hashicorp/aws/download/pkg.zip"}, - {"docker", "library/testimg/blobs/blobdata", "v2/library/testimg/blobs/blobdata"}, - } - - for _, tc := range cases { + for _, tc := range cachingCases { t.Run(tc.pkgType, func(t *testing.T) { name := "cache-" + tc.pkgType createRepo(t, fmt.Sprintf(`{ diff --git a/e2e-docker/fixtures/gems/mygem-1.0.0.gem b/e2e-docker/fixtures/gems/mygem-1.0.0.gem new file mode 100644 index 0000000..666781d --- /dev/null +++ b/e2e-docker/fixtures/gems/mygem-1.0.0.gem @@ -0,0 +1 @@ +mygem-1.0.0 e2e fixture diff --git a/e2e-docker/gems_local_test.go b/e2e-docker/gems_local_test.go new file mode 100644 index 0000000..17d1068 --- /dev/null +++ b/e2e-docker/gems_local_test.go @@ -0,0 +1,67 @@ +//go:build dockere2e + +package e2edocker + +import ( + "fmt" + "os" + "os/exec" + "strings" + "testing" + "time" +) + +// TestLocalGemsPushInstall drives stock gem and bundler clients against a +// local gems repo: push two versions, reject a republish, gem install and +// bundle install the latest via the compact index, then yank it and check a +// re-push of the yanked version with different bytes is rejected. +func TestLocalGemsPushInstall(t *testing.T) { + network := os.Getenv("COMPOSE_NETWORK") + internal := os.Getenv("ARTIFACTAPI_INTERNAL") + if network == "" || internal == "" { + t.Skip("COMPOSE_NETWORK/ARTIFACTAPI_INTERNAL not set; run via scripts/docker-e2e.sh") + } + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker not available on the test host") + } + + name := fmt.Sprintf("e2e-gems-local-%d", time.Now().UnixNano()) + createRepo(t, fmt.Sprintf(`{"name":%q,"package_type":"gems","repo_type":"local"}`, name)) + defer deleteRepo(t, name) + + src := strings.TrimRight(internal, "/") + "/api/v1/local/" + name + script := `set -euo pipefail +export GEM_HOST_API_KEY=unused +mk() { d=/w/src-$1${2:+-b}; mkdir -p $d/lib; cd $d + echo "module UnkinE2eHello; VERSION = '$1'; end${2:-}" > lib/unkin-e2e-hello.rb + printf 'Gem::Specification.new { |s| s.name = "unkin-e2e-hello"; s.version = "%s"; s.summary = "e2e"; s.authors = ["e2e"]; s.files = ["lib/unkin-e2e-hello.rb"] }' "$1" > g.gemspec + gem build g.gemspec >/dev/null 2>&1; } +mk 0.1.0 && gem push --host "$SRC" unkin-e2e-hello-0.1.0.gem +mk 0.2.0 && gem push --host "$SRC" unkin-e2e-hello-0.2.0.gem +if gem push --host "$SRC" unkin-e2e-hello-0.2.0.gem; then echo "republish accepted"; exit 1; fi +mkdir -p /w/app && cd /w/app +printf -- "---\n:sources:\n- $SRC/\n" > ~/.gemrc +gem install --no-document unkin-e2e-hello +ruby -e 'require "unkin-e2e-hello"; puts "GEM #{UnkinE2eHello::VERSION}"' +gem uninstall -a -x unkin-e2e-hello >/dev/null +printf 'source "%s/"\ngem "unkin-e2e-hello"\n' "$SRC" > Gemfile +bundle install +bundle exec ruby -e 'require "unkin-e2e-hello"; puts "BUNDLE #{UnkinE2eHello::VERSION}"' +gem yank unkin-e2e-hello -v 0.2.0 --host "$SRC" +echo "INFO $(curl -s "$SRC/info/unkin-e2e-hello" | cut -d' ' -f1 | tr '\n' ' ')" +mk 0.2.0 '# changed' && if gem push --host "$SRC" unkin-e2e-hello-0.2.0.gem; then echo "yanked re-push accepted"; exit 1; fi +cd /w/app && gem uninstall -a -x unkin-e2e-hello >/dev/null +if gem install --remote --no-document unkin-e2e-hello -v 0.2.0; then echo "yanked version installed"; exit 1; fi +echo "VERSIONS $(curl -s "$SRC/versions" | grep '^unkin-e2e-hello ' | cut -d' ' -f2)"` + + out, err := exec.Command("docker", "run", "--rm", "--network", network, + "-e", "SRC="+src, "ruby:3.3", "bash", "-c", script).CombinedOutput() + if err != nil { + t.Fatalf("gem client run failed: %v\n%s", err, out) + } + for _, want := range []string{"GEM 0.2.0", "BUNDLE 0.2.0", "INFO --- 0.1.0 ", "re-pushing a yanked version is not allowed", "VERSIONS 0.1.0\n"} { + if !strings.Contains(string(out), want) { + t.Fatalf("missing %q in gem output:\n%s", want, out) + } + } +} diff --git a/e2e-docker/gems_virtual_test.go b/e2e-docker/gems_virtual_test.go new file mode 100644 index 0000000..1eae0be --- /dev/null +++ b/e2e-docker/gems_virtual_test.go @@ -0,0 +1,69 @@ +//go:build dockere2e + +package e2edocker + +import ( + "fmt" + "os" + "os/exec" + "strings" + "testing" + "time" +) + +// TestVirtualGemsInstall pushes to two local gems repos and drives stock +// bundler through a virtual over both: merged versions, member order winning a +// duplicate version, and downloads redirected to the owning member. +func TestVirtualGemsInstall(t *testing.T) { + network := os.Getenv("COMPOSE_NETWORK") + internal := os.Getenv("ARTIFACTAPI_INTERNAL") + if network == "" || internal == "" { + t.Skip("COMPOSE_NETWORK/ARTIFACTAPI_INTERNAL not set; run via scripts/docker-e2e.sh") + } + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker not available on the test host") + } + + id := time.Now().UnixNano() + a, b, virt := fmt.Sprintf("e2e-gems-a-%d", id), fmt.Sprintf("e2e-gems-b-%d", id), fmt.Sprintf("e2e-gems-v-%d", id) + for _, name := range []string{a, b} { + createRepo(t, fmt.Sprintf(`{"name":%q,"package_type":"gems","repo_type":"local"}`, name)) + defer deleteRepo(t, name) + } + createVirtual(t, fmt.Sprintf(`{"name":%q,"package_type":"gems","members":[%q,%q]}`, virt, a, b)) + defer deleteVirtual(t, virt) + + base := strings.TrimRight(internal, "/") + script := `set -euo pipefail +export GEM_HOST_API_KEY=unused +pub() { d=/w/src-$1-$2; mkdir -p $d/lib; cd $d + echo "module UnkinE2eHello; VERSION = '$1:$2'; end" > lib/unkin-e2e-hello.rb + printf 'Gem::Specification.new { |s| s.name = "unkin-e2e-hello"; s.version = "%s"; s.summary = "e2e"; s.authors = ["e2e"]; s.files = ["lib/unkin-e2e-hello.rb"] }' "$2" > g.gemspec + gem build g.gemspec >/dev/null 2>&1; gem push --host "$BASE/api/v1/local/$1" unkin-e2e-hello-$2.gem; } +pub "$A" 0.1.0 +pub "$B" 0.1.0 +pub "$B" 0.2.0 +V="$BASE/api/v1/virtual/$V" +echo "VERSIONS $(curl -s "$V/versions" | grep '^unkin-e2e-hello ' | cut -d' ' -f2)" +mkdir -p /w/app && cd /w/app +printf 'source "%s/"\ngem "unkin-e2e-hello", "0.1.0"\n' "$V" > Gemfile +bundle install +bundle exec ruby -e 'require "unkin-e2e-hello"; puts "REQ #{UnkinE2eHello::VERSION}"' +echo "REDIRECT $(curl -s -o /dev/null -w '%{redirect_url}' "$V/gems/unkin-e2e-hello-0.2.0.gem")"` + + out, err := exec.Command("docker", "run", "--rm", "--network", network, + "-e", "BASE="+base, "-e", "A="+a, "-e", "B="+b, "-e", "V="+virt, + "ruby:3.3", "bash", "-c", script).CombinedOutput() + if err != nil { + t.Fatalf("bundler run failed: %v\n%s", err, out) + } + for _, want := range []string{ + "VERSIONS 0.1.0,0.2.0", + "REQ " + a + ":0.1.0", + "REDIRECT " + base + "/api/v1/local/" + b + "/gems/unkin-e2e-hello-0.2.0.gem", + } { + if !strings.Contains(string(out), want) { + t.Fatalf("missing %q in bundler output:\n%s", want, out) + } + } +} diff --git a/e2e-docker/helm_local_test.go b/e2e-docker/helm_local_test.go new file mode 100644 index 0000000..a0c9615 --- /dev/null +++ b/e2e-docker/helm_local_test.go @@ -0,0 +1,71 @@ +//go:build dockere2e + +package e2edocker + +import ( + "net/http" + "os" + "os/exec" + "strings" + "testing" + "time" +) + +// TestLocalHelmRealClient packages a chart with the real helm CLI, publishes it +// to a helm local, then drives helm repo add/search/pull/template against the +// local and against a helm virtual that has the local as a member. +func TestLocalHelmRealClient(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") + } + + createRepo(t, `{"name":"local-helm","package_type":"helm","repo_type":"local"}`) + defer deleteRepo(t, "local-helm") + createVirtual(t, `{"name":"vhelm","package_type":"helm","members":["local-helm"]}`) + defer deleteVirtual(t, "vhelm") + + helm := func(script string) string { + t.Helper() + out, err := exec.Command("docker", "run", "--rm", "--network", network, "--entrypoint", "sh", + "alpine/helm:3.16.4", "-c", "set -eu; "+script).Output() + if err != nil { + stderr := "" + if ee, ok := err.(*exec.ExitError); ok { + stderr = string(ee.Stderr) + } + t.Fatalf("helm script failed: %v\n%s%s", err, out, stderr) + } + return string(out) + } + + chart := helm("cd /tmp && helm create e2echart >/dev/null && helm package e2echart >/dev/null && cat e2echart-0.1.0.tgz") + uploadFile(t, "local-helm", "e2echart-0.1.0.tgz", []byte(chart), "application/gzip") + + // Chart metadata is recorded asynchronously after upload; poll the index. + var body []byte + for deadline := time.Now().Add(15 * time.Second); time.Now().Before(deadline); time.Sleep(500 * time.Millisecond) { + var resp *http.Response + resp, body = doRequest(t, http.MethodGet, api("/api/v1/local/local-helm/index.yaml"), nil, "") + if resp.StatusCode == http.StatusOK && strings.Contains(string(body), "e2echart-0.1.0.tgz") { + break + } + } + if !strings.Contains(string(body), "e2echart-0.1.0.tgz") || !strings.Contains(string(body), "digest: ") { + t.Fatalf("index.yaml never listed the chart:\n%s", body) + } + + for _, repoURL := range []string{internal + "/api/v1/local/local-helm", internal + "/api/v1/virtual/vhelm"} { + out := helm("helm repo add e2e " + repoURL + " >/dev/null && " + + "helm search repo e2e/e2echart && " + + "cd /tmp && helm pull e2e/e2echart --version 0.1.0 && " + + "helm template rel e2echart-0.1.0.tgz | grep -c 'kind: Deployment'") + if !strings.Contains(out, "e2e/e2echart") || !strings.Contains(out, "0.1.0") || !strings.HasSuffix(strings.TrimSpace(out), "1") { + t.Fatalf("helm via %s:\n%s", repoURL, out) + } + } +} diff --git a/e2e-docker/npm_local_test.go b/e2e-docker/npm_local_test.go new file mode 100644 index 0000000..9455611 --- /dev/null +++ b/e2e-docker/npm_local_test.go @@ -0,0 +1,62 @@ +//go:build dockere2e + +package e2edocker + +import ( + "fmt" + "os" + "os/exec" + "strings" + "testing" + "time" +) + +// TestLocalNPMPublishInstall drives a stock npm client against a local npm +// repo: publish unscoped and scoped packages, install and require() them in a +// fresh project, publish a second version, and reject a republish. +func TestLocalNPMPublishInstall(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-npm-local-%d", time.Now().UnixNano()) + createRepo(t, fmt.Sprintf(`{"name":%q,"package_type":"npm","repo_type":"local"}`, name)) + defer deleteRepo(t, name) + + reg := strings.TrimRight(internal, "/") + "/api/v1/local/" + name + "/" + script := `set -euo pipefail +printf 'registry=%s\n%s:_authToken=unused\n' "$REG" "${REG#http:}" > ~/.npmrc +mk() { d="/w/src/$1-$2"; mkdir -p "$d"; cd "$d" + printf '{"name":"%s","version":"%s","main":"index.js"}' "$1" "$2" > package.json + echo "module.exports = '$1@$2';" > index.js; } +mk e2e-npm-plain 1.0.0 && npm publish +mk @e2e/npm-scoped 1.0.0 && npm publish +mk e2e-npm-plain 1.0.0 +if npm publish 2>/dev/null; then echo "republish accepted"; exit 1; fi +mkdir -p /w/app && cd /w/app && npm init -y >/dev/null +npm install --no-audit --cache /tmp/fresh-cache e2e-npm-plain @e2e/npm-scoped +node -e "console.log('REQ', require('e2e-npm-plain'), require('@e2e/npm-scoped'))" +mk e2e-npm-plain 1.1.0 && npm publish +echo "VERSIONS $(npm view e2e-npm-plain versions --json | tr -d ' \n')" +echo "LATEST $(npm view e2e-npm-plain dist-tags.latest)"` + + out, err := exec.Command("docker", "run", "--rm", "--network", network, + "-e", "REG="+reg, "node:22", "bash", "-c", script).CombinedOutput() + if err != nil { + t.Fatalf("npm client run failed: %v\n%s", err, out) + } + for _, want := range []string{ + "REQ e2e-npm-plain@1.0.0 @e2e/npm-scoped@1.0.0", + `VERSIONS ["1.0.0","1.1.0"]`, + "LATEST 1.1.0", + } { + if !strings.Contains(string(out), want) { + t.Fatalf("missing %q in npm output:\n%s", want, out) + } + } +} diff --git a/e2e-docker/npm_virtual_test.go b/e2e-docker/npm_virtual_test.go new file mode 100644 index 0000000..31f92b0 --- /dev/null +++ b/e2e-docker/npm_virtual_test.go @@ -0,0 +1,68 @@ +//go:build dockere2e + +package e2edocker + +import ( + "fmt" + "os" + "os/exec" + "strings" + "testing" + "time" +) + +// TestVirtualNPMInstall publishes to two local npm repos and drives a stock +// npm client through a virtual over both: merged versions and dist-tags, and +// an install whose tarballs resolve via the virtual's member redirect. +func TestVirtualNPMInstall(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-npm-a-%d", id), fmt.Sprintf("e2e-npm-b-%d", id), fmt.Sprintf("e2e-npm-v-%d", id) + for _, name := range []string{a, b} { + createRepo(t, fmt.Sprintf(`{"name":%q,"package_type":"npm","repo_type":"local"}`, name)) + defer deleteRepo(t, name) + } + createVirtual(t, fmt.Sprintf(`{"name":%q,"package_type":"npm","members":[%q,%q]}`, virt, a, b)) + defer deleteVirtual(t, virt) + + base := strings.TrimRight(internal, "/") + script := `set -euo pipefail +pub() { d="/w/src/$2-$3-$1"; mkdir -p "$d"; cd "$d" + printf '{"name":"%s","version":"%s","main":"index.js"}' "$2" "$3" > package.json + echo "module.exports = '$1:$2@$3';" > index.js + printf 'registry=%s\n%s:_authToken=unused\n' "$BASE/api/v1/local/$1/" "${BASE#http:}/api/v1/local/$1/" > .npmrc + npm publish; } +pub "$A" @e2e/virt 1.0.0 +pub "$B" @e2e/virt 1.0.0 +pub "$B" @e2e/virt 1.1.0 +mkdir -p /w/app && cd /w/app && npm init -y >/dev/null +echo "registry=$BASE/api/v1/virtual/$V/" > .npmrc +echo "VERSIONS $(npm view @e2e/virt versions --json | tr -d ' \n')" +npm install --no-audit --cache /tmp/fresh-cache @e2e/virt@1.0.0 +node -e "console.log('REQ', require('@e2e/virt'))" +echo "RESOLVED $(node -p "require('./package-lock.json').packages['node_modules/@e2e/virt'].resolved")"` + + out, err := exec.Command("docker", "run", "--rm", "--network", network, + "-e", "BASE="+base, "-e", "A="+a, "-e", "B="+b, "-e", "V="+virt, + "node:22", "bash", "-c", script).CombinedOutput() + if err != nil { + t.Fatalf("npm client run failed: %v\n%s", err, out) + } + for _, want := range []string{ + `VERSIONS ["1.0.0","1.1.0"]`, + "REQ " + a + ":@e2e/virt@1.0.0", + "RESOLVED " + base + "/api/v1/virtual/" + virt + "/" + a + "/@e2e/virt/-/virt-1.0.0.tgz", + } { + if !strings.Contains(string(out), want) { + t.Fatalf("missing %q in npm output:\n%s", want, out) + } + } +} diff --git a/e2e-docker/puppet_local_test.go b/e2e-docker/puppet_local_test.go new file mode 100644 index 0000000..9c5acaa --- /dev/null +++ b/e2e-docker/puppet_local_test.go @@ -0,0 +1,88 @@ +//go:build dockere2e + +package e2edocker + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "net/http" + "os" + "os/exec" + "strings" + "testing" + "time" +) + +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() + 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", + } { + if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: int64(len(body)), ModTime: time.Now()}); err != nil { + t.Fatal(err) + } + _, _ = tw.Write([]byte(body)) + } + _ = tw.Close() + _ = zw.Close() + return buf.Bytes() +} + +// TestLocalPuppetRealClient publishes a module to a puppet local and installs +// it with the real r10k and puppet module tool. The stdlib dependency is not on +// a local-only forge, so puppet runs with --ignore-dependencies. +func TestLocalPuppetRealClient(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") + } + + createRepo(t, `{"name":"local-puppet","package_type":"puppet","repo_type":"local"}`) + defer deleteRepo(t, "local-puppet") + + upload := api("/api/v2/remotes/local-puppet/files/unkin-hello-0.1.0.tar.gz") + defer doRequest(t, http.MethodDelete, api("/api/v2/remotes/local-puppet/files/v3/files/unkin-hello-0.1.0.tar.gz"), nil, "") + tarball := puppetModuleTarball(t) + if resp, body := doRequest(t, http.MethodPut, upload, tarball, "application/gzip"); resp.StatusCode != http.StatusCreated { + t.Fatalf("upload: %d %s", resp.StatusCode, body) + } + if resp, _ := doRequest(t, http.MethodPut, upload, tarball, "application/gzip"); resp.StatusCode != http.StatusConflict { + t.Fatalf("re-upload of an existing version: %d, want 409", resp.StatusCode) + } + if resp, body := getEventually(t, api("/api/v1/local/local-puppet/v3/releases/unkin-hello-0.1.0"), 15*time.Second); resp.StatusCode != http.StatusOK || !strings.Contains(string(body), `"file_md5"`) { + t.Fatalf("release never indexed: %d %s", resp.StatusCode, body) + } + + forge := internal + "/api/v1/local/local-puppet" + 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/voxpupuli/r10k:latest", "cd /tmp && printf 'forge:\\n baseurl: "+forge+"\\n' > r10k.yaml && "+ + "printf \"forge '"+forge+"'\\nmod 'unkin-hello', '0.1.0'\\n\" > Puppetfile && "+ + "r10k puppetfile install -c r10k.yaml --verbose && cat modules/hello/metadata.json") + if !strings.Contains(out, `"unkin-hello"`) { + t.Fatalf("r10k did not install unkin-hello:\n%s", out) + } + + out = run("ghcr.io/openvoxproject/openvoxagent:latest", "export PATH=/opt/puppetlabs/bin:$PATH; puppet module install unkin-hello --module_repository "+forge+" --ignore-dependencies --target-dir /tmp/mods && puppet module list --color=false --modulepath /tmp/mods") + if !strings.Contains(out, "unkin-hello (v0.1.0)") { + t.Fatalf("puppet module install did not install unkin-hello:\n%s", out) + } +} diff --git a/e2e/head_test.go b/e2e/head_test.go new file mode 100644 index 0000000..e222551 --- /dev/null +++ b/e2e/head_test.go @@ -0,0 +1,182 @@ +//go:build e2e + +package e2e + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" + + "git.unkin.net/unkin/artifactapi/internal/testsupport" +) + +// assertHeadMatchesGet checks HEAD returns GET's status, type and length with no body. +func assertHeadMatchesGet(t *testing.T, url string) { + t.Helper() + get, err := http.Get(url) + if err != nil { + t.Fatalf("GET: %v", err) + } + body, _ := io.ReadAll(get.Body) + get.Body.Close() + if get.StatusCode != http.StatusOK { + t.Fatalf("GET %s: %d %s", url, get.StatusCode, body) + } + + head, err := http.Head(url) + if err != nil { + t.Fatalf("HEAD: %v", err) + } + hb, _ := io.ReadAll(head.Body) + head.Body.Close() + if head.StatusCode != http.StatusOK { + t.Fatalf("HEAD %s: got %d, want 200", url, head.StatusCode) + } + if len(hb) != 0 { + t.Errorf("HEAD returned a %d-byte body", len(hb)) + } + for _, h := range []string{"Content-Type", "Cache-Control", "Vary"} { + if head.Header.Get(h) != get.Header.Get(h) { + t.Errorf("HEAD %s %q, GET %q", h, head.Header.Get(h), get.Header.Get(h)) + } + } + if head.ContentLength != int64(len(body)) { + t.Errorf("HEAD Content-Length %d, GET body %d bytes", head.ContentLength, len(body)) + } +} + +func TestProxyHeadNuGetRewritten(t *testing.T) { + var upstream *httptest.Server + upstream = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{"version": "3.0.0", "resources": []map[string]string{ + {"@id": upstream.URL + "/v3-flatcontainer/", "@type": "PackageBaseAddress/3.0.0"}, + }}) + })) + defer upstream.Close() + + createRemote(t, mustFormat(`{"name": "head-nuget", "package_type": "nuget", "base_url": %q}`, upstream.URL+"/v3/index.json")) + defer deleteRemote(t, "head-nuget") + + url := apiURL("/api/v1/remote/head-nuget/index.json") + assertHeadMatchesGet(t, url) + + req, _ := http.NewRequest(http.MethodHead, url, nil) + req.Host = "evil.example:" + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("HEAD with invalid Host: got %d, want 400", resp.StatusCode) + } +} + +func TestProxyHeadGitHubRPMRepodata(t *testing.T) { + rpm := testsupport.MinimalRPM("demo", "1.2", "3", "x86_64") + var gh *httptest.Server + mux := http.NewServeMux() + mux.HandleFunc("/repos/acme/tools/releases", func(w http.ResponseWriter, r *http.Request) { + if p := r.URL.Query().Get("page"); p != "" && p != "1" { + w.Write([]byte("[]")) + return + } + json.NewEncoder(w).Encode([]map[string]any{{"tag_name": "v1.2-3", "assets": []map[string]any{{ + "name": "demo-1.2-3.x86_64.rpm", + "size": len(rpm), + "browser_download_url": gh.URL + "/acme/tools/releases/download/v1.2-3/demo-1.2-3.x86_64.rpm", + }}}}) + }) + mux.HandleFunc("/acme/tools/releases/download/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Length", strconv.Itoa(len(rpm))) + w.Write(rpm) + }) + gh = httptest.NewServer(mux) + defer gh.Close() + + createRemote(t, mustFormat(`{"name": "head-ghrpm", "package_type": "github_rpm", "base_url": %q, "releases_remote": "github"}`, gh.URL+"/repos/acme/tools")) + defer deleteRemote(t, "head-ghrpm") + + url := apiURL("/api/v1/remote/head-ghrpm/repodata/repomd.xml") + deadline := time.Now().Add(30 * time.Second) + for { + if _, status := getBody(t, url); status == http.StatusOK || time.Now().After(deadline) { + break + } + time.Sleep(500 * time.Millisecond) + } + assertHeadMatchesGet(t, url) +} + +func TestProxyHeadNpmUncachedPackument(t *testing.T) { + const abbreviated = "application/vnd.npm.install-v1+json" + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + doc := map[string]any{"name": "left-pad", "versions": map[string]any{"1.3.0": map[string]any{}}} + if r.Header.Get("Accept") != abbreviated { + doc["readme"] = strings.Repeat("full packument ", 200) + } + json.NewEncoder(w).Encode(doc) + })) + defer upstream.Close() + + createRemote(t, mustFormat(`{"name": "head-npm", "package_type": "npm", "base_url": %q}`, upstream.URL)) + defer deleteRemote(t, "head-npm") + + url := apiURL("/api/v1/remote/head-npm/left-pad") + do := func(method string) (*http.Response, []byte) { + req, _ := http.NewRequest(method, url, nil) + req.Header.Set("Accept", abbreviated) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("%s: %v", method, err) + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + return resp, b + } + head, _ := do(http.MethodHead) + get, body := do(http.MethodGet) + if head.StatusCode != http.StatusOK || get.StatusCode != http.StatusOK { + t.Fatalf("HEAD %d, GET %d", head.StatusCode, get.StatusCode) + } + if head.ContentLength != int64(len(body)) { + t.Errorf("uncached HEAD Content-Length %d, GET body %d bytes", head.ContentLength, len(body)) + } +} + +func TestProxyHeadNonRewritingUsesUpstreamHead(t *testing.T) { + methods := make(chan string, 4) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + methods <- r.Method + w.Header().Set("Content-Type", "text/xml") + w.Write([]byte("")) + })) + defer upstream.Close() + + createRemote(t, mustFormat(`{"name": "head-rpm", "package_type": "rpm", "base_url": %q}`, upstream.URL)) + defer deleteRemote(t, "head-rpm") + + resp, err := http.Head(apiURL("/api/v1/remote/head-rpm/repodata/repomd.xml")) + if err != nil { + t.Fatalf("HEAD: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK || resp.Header.Get("X-Artifact-Source") != "remote" { + t.Fatalf("HEAD %d source %q", resp.StatusCode, resp.Header.Get("X-Artifact-Source")) + } + close(methods) + var seen []string + for m := range methods { + seen = append(seen, m) + } + if len(seen) != 1 || seen[0] != http.MethodHead { + t.Errorf("upstream saw %v, want a single HEAD", seen) + } +} diff --git a/e2e/scheme_test.go b/e2e/scheme_test.go new file mode 100644 index 0000000..4ab636f --- /dev/null +++ b/e2e/scheme_test.go @@ -0,0 +1,59 @@ +//go:build e2e + +package e2e + +import ( + "encoding/json" + "net/http" + "strings" + "testing" +) + +func getForwardedHTTPS(t *testing.T, url string) *http.Response { + t.Helper() + req, _ := http.NewRequest(http.MethodGet, url, nil) + req.Header.Set("X-Forwarded-Proto", "https") + client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + resp, err := client.Do(req) + if err != nil { + t.Fatalf("GET %s: %v", url, err) + } + return resp +} + +func TestForwardedProtoCargoConfig(t *testing.T) { + createRemote(t, `{"name": "scheme-cargo", "package_type": "cargo", "base_url": "https://index.crates.io"}`) + defer deleteRemote(t, "scheme-cargo") + + resp := getForwardedHTTPS(t, apiURL("/api/v1/remote/scheme-cargo/config.json")) + defer resp.Body.Close() + var cfg map[string]string + if err := json.NewDecoder(resp.Body).Decode(&cfg); err != nil { + t.Fatal(err) + } + if want := "https://" + strings.TrimPrefix(baseURL, "http://") + "/api/v1/remote/scheme-cargo/"; !strings.HasPrefix(cfg["dl"], want) { + t.Errorf("dl = %q, want prefix %q", cfg["dl"], want) + } +} + +func TestForwardedProtoVirtualRedirect(t *testing.T) { + createRemote(t, `{"name": "scheme-rpm", "package_type": "rpm", "base_url": "https://rpm.example.com"}`) + defer deleteRemote(t, "scheme-rpm") + resp, err := http.Post(apiURL("/api/v2/virtuals"), "application/json", + strings.NewReader(`{"name": "scheme-virt", "package_type": "rpm", "members": ["scheme-rpm"]}`)) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create virtual: status %d", resp.StatusCode) + } + defer deleteRequest(t, apiURL("/api/v2/virtuals/scheme-virt")) + + resp = getForwardedHTTPS(t, apiURL("/api/v1/virtual/scheme-virt/scheme-rpm/Packages/a.rpm")) + resp.Body.Close() + loc := resp.Header.Get("Location") + if want := "https://" + strings.TrimPrefix(baseURL, "http://") + "/api/v1/remote/scheme-rpm/Packages/a.rpm"; resp.StatusCode != http.StatusFound || loc != want { + t.Errorf("redirect = %d %q, want 302 %q", resp.StatusCode, loc, want) + } +} diff --git a/internal/api/terraform/registry.go b/internal/api/terraform/registry.go index 7aee9de..cd69aca 100644 --- a/internal/api/terraform/registry.go +++ b/internal/api/terraform/registry.go @@ -13,6 +13,7 @@ import ( "github.com/go-chi/chi/v5" + v1 "git.unkin.net/unkin/artifactapi/internal/api/v1" "git.unkin.net/unkin/artifactapi/internal/database" tfprov "git.unkin.net/unkin/artifactapi/internal/provider/terraform" "git.unkin.net/unkin/artifactapi/internal/tfsign" @@ -290,12 +291,5 @@ func writeJSON(w http.ResponseWriter, v any) { } func baseURL(r *http.Request) string { - scheme := "http" - if r.TLS != nil { - scheme = "https" - } - if fwd := r.Header.Get("X-Forwarded-Proto"); fwd != "" { - scheme = fwd - } - return scheme + "://" + r.Host + return v1.Scheme(r) + "://" + r.Host } diff --git a/internal/api/v1/gems.go b/internal/api/v1/gems.go new file mode 100644 index 0000000..912777f --- /dev/null +++ b/internal/api/v1/gems.go @@ -0,0 +1,308 @@ +package v1 + +import ( + "cmp" + "context" + "errors" + "fmt" + "io" + "log/slog" + "maps" + "net/http" + "net/url" + "slices" + "strings" + "time" + + "github.com/go-chi/chi/v5" + + "git.unkin.net/unkin/artifactapi/internal/database" + "git.unkin.net/unkin/artifactapi/internal/provider/gems" + "git.unkin.net/unkin/artifactapi/internal/virtual" + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +// Local gems repos speak the RubyGems push/yank API and compact index. Per gem: +// gems/.gem package (immutable) +// quick/Marshal.4.8/.gemspec.rz marshalled spec (immutable) +// _index// compact index /info line; gates visibility +// _yanked// yank tombstone; blocks re-push of the version + +var ( + gemsMaxPushBytes int64 = 256 << 20 + gemsLockWait = 30 * time.Second +) + +const ( + gemsIndexPrefix = "_index/" + gemsYankedPrefix = "_yanked/" +) + +func gemsText(w http.ResponseWriter, status int, msg string) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(status) + _, _ = io.WriteString(w, msg) +} + +func (h *ProxyHandler) localGemsRemote(w http.ResponseWriter, r *http.Request) (*models.Remote, bool) { + remote, err := h.db.GetRemote(r.Context(), chi.URLParam(r, "localName")) + if err != nil || remote.RepoType != models.RepoTypeLocal || remote.PackageType != models.PackageGems { + gemsText(w, http.StatusMethodNotAllowed, "only supported on local gems repositories") + return nil, false + } + return remote, true +} + +// lockGemVersion serialises push and yank of one gem version across replicas. +func (h *ProxyHandler) lockGemVersion(w http.ResponseWriter, r *http.Request, repo, full string) (func(), bool) { + unlock, err := h.engine.Lock(r.Context(), repo, "_gemlock/"+full, gemsLockWait, 5*time.Minute) + if err != nil { + gemsText(w, http.StatusServiceUnavailable, full+" is being pushed or yanked, retry") + return nil, false + } + return unlock, true +} + +func (h *ProxyHandler) handleLocalPost(w http.ResponseWriter, r *http.Request) { + if chi.URLParam(r, "*") != "api/v1/gems" { + gemsText(w, http.StatusNotFound, "not found") + return + } + remote, ok := h.localGemsRemote(w, r) + if !ok { + return + } + gem, err := io.ReadAll(http.MaxBytesReader(w, r.Body, gemsMaxPushBytes)) + if err != nil { + gemsText(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("gem exceeds %d bytes", gemsMaxPushBytes)) + return + } + spec, err := gems.ParseGem(gem) + if err != nil { + gemsText(w, http.StatusBadRequest, err.Error()) + return + } + quick, err := gems.QuickSpec(spec) + if err != nil { + gemsText(w, http.StatusInternalServerError, err.Error()) + return + } + + full := spec.FullName() + unlock, ok := h.lockGemVersion(w, r, remote.Name, full) + if !ok { + return + } + defer unlock() + conflict := func() { + gemsText(w, http.StatusConflict, fmt.Sprintf("%s already exists; overwrites are not allowed", full)) + } + index := gemsIndexPrefix + spec.Name + "/" + full + for _, p := range []string{gemsYankedPrefix + spec.Name + "/" + full, index} { + f, err := h.db.GetLocalFile(r.Context(), remote.Name, p) + if err != nil { + gemsText(w, http.StatusInternalServerError, err.Error()) + return + } + if f != nil && p == index { + conflict() + return + } + if f != nil { + gemsText(w, http.StatusConflict, fmt.Sprintf("%s was yanked; re-pushing a yanked version is not allowed", full)) + return + } + } + // The version is not live, so on failure every path is an invisible leftover and safe to drop. + paths := []string{"gems/" + full + ".gem", "quick/Marshal.4.8/" + full + ".gemspec.rz", index} + fail := func(status int, msg string) { + for _, p := range paths { + if err := h.db.DeleteLocalFile(context.WithoutCancel(r.Context()), remote.Name, p); err != nil { + slog.Error("gem push cleanup failed", "repo", remote.Name, "path", p, "error", err) + } + } + gemsText(w, status, msg) + } + // The index line gates visibility, so it is written last. + for _, f := range []struct { + path string + data []byte + }{ + {paths[0], gem}, + {paths[1], quick}, + } { + ok, err := h.npmStoreImmutable(r.Context(), remote.Name, f.path, f.data, "application/octet-stream") + if err != nil { + fail(http.StatusInternalServerError, err.Error()) + return + } + if !ok { + fail(http.StatusConflict, fmt.Sprintf("%s already exists; overwrites are not allowed", full)) + return + } + } + line := gems.InfoLine(spec, gem) + if _, err := h.npmStore(r.Context(), remote.Name, index, []byte(line), "text/plain", false); err != nil { + fail(http.StatusInternalServerError, err.Error()) + return + } + + slog.Info("local gem pushed", "repo", remote.Name, "gem", full) + gemsText(w, http.StatusOK, "Successfully registered gem: "+spec.Name+" ("+spec.Version+")") +} + +func (h *ProxyHandler) handleLocalDelete(w http.ResponseWriter, r *http.Request) { + if chi.URLParam(r, "*") != "api/v1/gems/yank" { + gemsText(w, http.StatusNotFound, "not found") + return + } + remote, ok := h.localGemsRemote(w, r) + if !ok { + return + } + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 64<<10)) + if err != nil { + gemsText(w, http.StatusRequestEntityTooLarge, "yank body too large") + return + } + form, err := url.ParseQuery(string(body)) + if err != nil { + gemsText(w, http.StatusBadRequest, "invalid form body") + return + } + for k, v := range r.URL.Query() { + if form.Get(k) == "" { + form[k] = v + } + } + spec := &gems.Spec{Name: form.Get("gem_name"), Version: form.Get("version"), Platform: form.Get("platform")} + if spec.Platform == "" { + spec.Platform = "ruby" + } + if !gems.NameRe.MatchString(spec.Name) || !gems.VersionRe.MatchString(spec.Version) || !gems.PlatformRe.MatchString(spec.Platform) { + gemsText(w, http.StatusBadRequest, "invalid gem_name, version or platform") + return + } + + full := spec.FullName() + unlock, ok := h.lockGemVersion(w, r, remote.Name, full) + if !ok { + return + } + defer unlock() + index := gemsIndexPrefix + spec.Name + "/" + full + existing, err := h.db.GetLocalFile(r.Context(), remote.Name, index) + if err != nil { + gemsText(w, http.StatusInternalServerError, err.Error()) + return + } + if existing == nil { + gemsText(w, http.StatusNotFound, "The version "+spec.Version+" does not exist") + return + } + // The tombstone keeps the yanked line and is written before any delete. + line, err := h.readLocalBlob(r.Context(), existing.ContentHash) + if err != nil { + gemsText(w, http.StatusInternalServerError, err.Error()) + return + } + if _, err := h.npmStore(r.Context(), remote.Name, gemsYankedPrefix+spec.Name+"/"+full, line, "text/plain", false); err != nil && !errors.Is(err, database.ErrAlreadyExists) { + gemsText(w, http.StatusInternalServerError, err.Error()) + return + } + for _, p := range []string{index, "gems/" + full + ".gem", "quick/Marshal.4.8/" + full + ".gemspec.rz"} { + if err := h.db.DeleteLocalFile(context.WithoutCancel(r.Context()), remote.Name, p); err != nil { + gemsText(w, http.StatusInternalServerError, err.Error()) + return + } + } + slog.Info("local gem yanked", "repo", remote.Name, "gem", full) + gemsText(w, http.StatusOK, "Successfully deleted gem: "+spec.Name+" ("+spec.Version+")") +} + +// isGemsIndexPath reports whether path is a compact index document. +func isGemsIndexPath(path string) bool { + return path == "versions" || path == "names" || strings.HasPrefix(path, "info/") +} + +func (h *ProxyHandler) serveLocalGemsIndex(w http.ResponseWriter, r *http.Request, remote *models.Remote, path string) { + body, err := h.LocalGemsIndex(r.Context(), remote.Name, path) + if errors.Is(err, virtual.ErrNotFound) { + gemsText(w, http.StatusNotFound, "not found") + return + } + if err != nil { + gemsText(w, http.StatusInternalServerError, err.Error()) + return + } + w.Header().Set("X-Artifact-Source", "local") + gemsText(w, http.StatusOK, string(body)) +} + +// LocalGemsIndex builds a local gems repo's versions, names or info/ +// document, or returns virtual.ErrNotFound. +func (h *ProxyHandler) LocalGemsIndex(ctx context.Context, repo, path string) ([]byte, error) { + if name, ok := strings.CutPrefix(path, "info/"); ok { + if !gems.NameRe.MatchString(name) { + return nil, virtual.ErrNotFound + } + lines, err := h.localGemsLines(ctx, repo, gemsIndexPrefix+name+"/") + if err != nil { + return nil, err + } + if len(lines[name]) == 0 { + return nil, virtual.ErrNotFound + } + return gems.InfoBody(lines[name]), nil + } + if path != "versions" && path != "names" { + return nil, virtual.ErrNotFound + } + lines, err := h.localGemsLines(ctx, repo, gemsIndexPrefix) + if err != nil { + return nil, err + } + names := slices.Sorted(maps.Keys(lines)) + var b strings.Builder + if path == "names" { + b.WriteString("---\n") + for _, n := range names { + b.WriteString(n + "\n") + } + return []byte(b.String()), nil + } + b.WriteString(gems.VersionsHeader(time.Now())) + for _, n := range names { + b.WriteString(gems.VersionsLine(n, gems.InfoBody(lines[n])) + "\n") + } + return []byte(b.String()), nil +} + +// localGemsLines returns /info lines per gem name in push order. +func (h *ProxyHandler) localGemsLines(ctx context.Context, repo, prefix string) (map[string][]string, error) { + files, err := h.db.ListLocalFilesByPrefix(ctx, repo, prefix) + if err != nil { + return nil, err + } + slices.SortFunc(files, func(a, b database.LocalFile) int { return cmp.Compare(a.ID, b.ID) }) + out := map[string][]string{} + for _, f := range files { + // LIKE treats '_' as a wildcard, so the prefix match can over-select. + rest, ok := strings.CutPrefix(f.FilePath, prefix) + if !ok { + continue + } + name := strings.TrimPrefix(prefix, gemsIndexPrefix) + if name == "" { + name, _, _ = strings.Cut(rest, "/") + } else { + name = strings.TrimSuffix(name, "/") + } + line, err := h.readLocalBlob(ctx, f.ContentHash) + if err != nil { + return nil, err + } + out[name] = append(out[name], string(line)) + } + return out, nil +} diff --git a/internal/api/v1/gems_handler_test.go b/internal/api/v1/gems_handler_test.go new file mode 100644 index 0000000..c1cc0cc --- /dev/null +++ b/internal/api/v1/gems_handler_test.go @@ -0,0 +1,254 @@ +package v1 + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "crypto/md5" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "strings" + "sync" + "testing" + "time" + + "git.unkin.net/unkin/artifactapi/internal/cache" + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +func testGemFile(t *testing.T, name, version, platform string) []byte { + t.Helper() + meta := fmt.Sprintf(`--- !ruby/object:Gem::Specification +name: %s +version: !ruby/object:Gem::Version + version: %s +platform: %s +dependencies: +- !ruby/object:Gem::Dependency + name: rack + requirement: !ruby/object:Gem::Requirement + requirements: + - - "~>" + - !ruby/object:Gem::Version + version: '3.0' + type: :runtime + prerelease: false +rubygems_version: 3.5.22 +specification_version: 4 +summary: test +`, name, version, platform) + var gz bytes.Buffer + zw := gzip.NewWriter(&gz) + _, _ = zw.Write([]byte(meta)) + _ = zw.Close() + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + _ = tw.WriteHeader(&tar.Header{Name: "metadata.gz", Mode: 0o444, Size: int64(gz.Len())}) + _, _ = tw.Write(gz.Bytes()) + _ = tw.Close() + return buf.Bytes() +} + +func sha256Hex(b []byte) string { + s := sha256.Sum256(b) + return hex.EncodeToString(s[:]) +} + +func TestGemsPushIndexYank(t *testing.T) { + h, srv := npmTestServer(t) + repo := npmRepo(t, h, models.PackageGems) + base := srv.URL + "/api/v1/local/" + repo + "/" + + g1 := testGemFile(t, "hello_gem", "0.1.0", "ruby") + g2 := testGemFile(t, "hello_gem", "0.2.0", "ruby") + gj := testGemFile(t, "hello_gem", "0.2.0", "java") + other := testGemFile(t, "hello-gem", "1.0.0", "ruby") + for _, g := range [][]byte{g1, g2, gj, other} { + if code, body := npmDo(t, http.MethodPost, base+"api/v1/gems", string(g)); code != http.StatusOK { + t.Fatalf("push = %d: %s", code, body) + } + } + if code, _ := npmDo(t, http.MethodPost, base+"api/v1/gems", string(g1)+"x"); code != http.StatusConflict { + t.Fatalf("repush with different bytes = %d, want 409", code) + } + if code, _ := npmDo(t, http.MethodPost, base+"api/v1/gems", string(g1)); code != http.StatusConflict { + t.Fatalf("identical repush = %d, want 409", code) + } + if code, _ := npmDo(t, http.MethodPost, base+"api/v1/gems", "not a gem"); code != http.StatusBadRequest { + t.Fatalf("garbage push = %d, want 400", code) + } + + code, info := npmDo(t, http.MethodGet, base+"info/hello_gem", "") + want := "---\n0.1.0 rack:~> 3.0|checksum:" + sha256Hex(g1) + "\n0.2.0 rack:~> 3.0|checksum:" + sha256Hex(g2) + + "\n0.2.0-java rack:~> 3.0|checksum:" + sha256Hex(gj) + "\n" + if code != http.StatusOK || info != want { + t.Fatalf("info = %d\n%q\nwant\n%q", code, info, want) + } + if code, _ := npmDo(t, http.MethodHead, base+"versions", ""); code != http.StatusOK { + t.Fatalf("HEAD versions = %d; gem probes the compact index with HEAD", code) + } + _, versions := npmDo(t, http.MethodGet, base+"versions", "") + head, body, ok := strings.Cut(versions, "\n---\n") + if !ok || !strings.HasPrefix(head, "created_at: ") { + t.Fatalf("versions header = %q", versions) + } + _, otherInfo := npmDo(t, http.MethodGet, base+"info/hello-gem", "") + wantBody := fmt.Sprintf("hello-gem 1.0.0 %x\nhello_gem 0.1.0,0.2.0,0.2.0-java %x\n", md5.Sum([]byte(otherInfo)), md5.Sum([]byte(info))) + if body != wantBody { + t.Fatalf("versions body =\n%q\nwant\n%q", body, wantBody) + } + if _, names := npmDo(t, http.MethodGet, base+"names", ""); names != "---\nhello-gem\nhello_gem\n" { + t.Fatalf("names = %q", names) + } + if code, got := npmDo(t, http.MethodGet, base+"gems/hello_gem-0.2.0-java.gem", ""); code != http.StatusOK || got != string(gj) { + t.Fatalf("download = %d", code) + } + if code, _ := npmDo(t, http.MethodGet, base+"quick/Marshal.4.8/hello_gem-0.1.0.gemspec.rz", ""); code != http.StatusOK { + t.Fatalf("quick spec = %d", code) + } + if code, _ := npmDo(t, http.MethodGet, base+"info/missing", ""); code != http.StatusNotFound { + t.Fatalf("missing info = %d", code) + } + + yank := func(form string) int { + req, _ := http.NewRequest(http.MethodDelete, base+"api/v1/gems/yank", strings.NewReader(form)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + _ = resp.Body.Close() + return resp.StatusCode + } + if c := yank("gem_name=hello_gem&version=0.2.0&platform=java"); c != http.StatusOK { + t.Fatalf("yank = %d", c) + } + if c := yank("gem_name=hello_gem&version=0.2.0&platform=java"); c != http.StatusNotFound { + t.Fatalf("second yank = %d, want 404", c) + } + if c := yank("gem_name=../x&version=1"); c != http.StatusBadRequest { + t.Fatalf("bad yank = %d, want 400", c) + } + if _, info := npmDo(t, http.MethodGet, base+"info/hello_gem", ""); strings.Contains(info, "java") || !strings.Contains(info, "0.2.0 ") { + t.Fatalf("info after yank = %q", info) + } + if code, _ := npmDo(t, http.MethodGet, base+"gems/hello_gem-0.2.0-java.gem", ""); code != http.StatusNotFound { + t.Fatalf("yanked download = %d, want 404", code) + } + if _, v := npmDo(t, http.MethodGet, base+"versions", ""); strings.Contains(v, "java") || !strings.Contains(v, "hello_gem 0.1.0,0.2.0 ") { + t.Fatalf("versions after yank = %q", v) + } + for _, g := range []string{string(gj), string(gj) + "x"} { + if code, body := npmDo(t, http.MethodPost, base+"api/v1/gems", g); code != http.StatusConflict || !strings.Contains(body, "yanked") { + t.Fatalf("push after yank = %d %q, want 409", code, body) + } + } + if _, info := npmDo(t, http.MethodGet, base+"info/hello_gem", ""); strings.Contains(info, "java") { + t.Fatalf("info after rejected re-push = %q", info) + } + + if c := yank("gem_name=hello-gem&version=1.0.0"); c != http.StatusOK { + t.Fatalf("yank last version = %d", c) + } + if code, _ := npmDo(t, http.MethodGet, base+"info/hello-gem", ""); code != http.StatusNotFound { + t.Fatalf("info for fully yanked gem = %d, want 404", code) + } + if _, names := npmDo(t, http.MethodGet, base+"names", ""); names != "---\nhello_gem\n" { + t.Fatalf("names after yanking every version = %q", names) + } +} + +func TestGemsPushRejects(t *testing.T) { + h, srv := npmTestServer(t) + npmLocal := npmRepo(t, h, models.PackageNPM) + g := string(testGemFile(t, "g", "1.0.0", "ruby")) + if code, _ := npmDo(t, http.MethodPost, srv.URL+"/api/v1/local/"+npmLocal+"/api/v1/gems", g); code != http.StatusMethodNotAllowed { + t.Fatalf("push to npm local = %d, want 405", code) + } + repo := npmRepo(t, h, models.PackageGems) + if code, _ := npmDo(t, http.MethodPost, srv.URL+"/api/v1/local/"+repo+"/other", g); code != http.StatusNotFound { + t.Fatalf("push to other path = %d, want 404", code) + } + old := gemsMaxPushBytes + gemsMaxPushBytes = 64 + defer func() { gemsMaxPushBytes = old }() + if code, _ := npmDo(t, http.MethodPost, srv.URL+"/api/v1/local/"+repo+"/api/v1/gems", g); code != http.StatusRequestEntityTooLarge { + t.Fatalf("oversized push = %d, want 413", code) + } +} + +func TestGemsPushYankSerialised(t *testing.T) { + h, srv := npmTestServer(t) + repo := npmRepo(t, h, models.PackageGems) + base := srv.URL + "/api/v1/local/" + repo + "/" + g := string(testGemFile(t, "race", "1.0.0", "ruby")) + var wg sync.WaitGroup + for i := 0; i < 16; i++ { + wg.Add(2) + go func() { + defer wg.Done() + if code, body := npmDo(t, http.MethodPost, base+"api/v1/gems", g); code != http.StatusOK && code != http.StatusConflict { + t.Errorf("push = %d: %s", code, body) + } + }() + go func() { + defer wg.Done() + if code, body := npmDo(t, http.MethodDelete, base+"api/v1/gems/yank?gem_name=race&version=1.0.0", ""); code != http.StatusOK && code != http.StatusNotFound { + t.Errorf("yank = %d: %s", code, body) + } + }() + } + wg.Wait() + + present := func(p string) bool { + f, err := h.db.GetLocalFile(context.Background(), repo, p) + if err != nil { + t.Fatal(err) + } + return f != nil + } + live := []string{"_index/race/race-1.0.0", "gems/race-1.0.0.gem", "quick/Marshal.4.8/race-1.0.0.gemspec.rz"} + tomb := present("_yanked/race/race-1.0.0") + for _, p := range live { + if present(p) == tomb { + t.Errorf("tombstoned=%v but %s present=%v", tomb, p, !tomb) + } + } + _, info := npmDo(t, http.MethodGet, base+"info/race", "") + if strings.Contains(info, "1.0.0") == tomb { + t.Errorf("tombstoned=%v but info = %q", tomb, info) + } +} + +func TestGemsLockTimeoutIs503(t *testing.T) { + h, srv := npmTestServer(t) + repo := npmRepo(t, h, models.PackageGems) + base := srv.URL + "/api/v1/local/" + repo + "/" + redis, err := cache.NewRedis(testRedis) + if err != nil { + t.Fatal(err) + } + token, err := redis.AcquireLock(context.Background(), repo, "_gemlock/busy-1.0.0", time.Minute) + if token == "" || err != nil { + t.Fatalf("hold lock = %q %v", token, err) + } + prev := gemsLockWait + gemsLockWait = 100 * time.Millisecond + t.Cleanup(func() { gemsLockWait = prev }) + + if code, _ := npmDo(t, http.MethodPost, base+"api/v1/gems", string(testGemFile(t, "busy", "1.0.0", "ruby"))); code != http.StatusServiceUnavailable { + t.Fatalf("push under held lock = %d, want 503", code) + } + if code, _ := npmDo(t, http.MethodDelete, base+"api/v1/gems/yank?gem_name=busy&version=1.0.0", ""); code != http.StatusServiceUnavailable { + t.Fatalf("yank under held lock = %d, want 503", code) + } + if err := redis.ReleaseLock(context.Background(), repo, "_gemlock/busy-1.0.0", token); err != nil { + t.Fatal(err) + } + if code, body := npmDo(t, http.MethodPost, base+"api/v1/gems", string(testGemFile(t, "busy", "1.0.0", "ruby"))); code != http.StatusOK { + t.Fatalf("push after release = %d: %s", code, body) + } +} diff --git a/internal/api/v1/gems_virtual.go b/internal/api/v1/gems_virtual.go new file mode 100644 index 0000000..fbaf751 --- /dev/null +++ b/internal/api/v1/gems_virtual.go @@ -0,0 +1,39 @@ +package v1 + +import ( + "errors" + "log/slog" + "net/http" + + "git.unkin.net/unkin/artifactapi/internal/virtual" + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +// serveVirtualGems serves the merged compact index and 302s downloads to the +// member owning that version. +func (h *ProxyHandler) serveVirtualGems(w http.ResponseWriter, r *http.Request, virt models.Virtual, path, proxyBaseURL string) { + var body []byte + var loc string + var err error + if isGemsIndexPath(path) { + body, _, err = h.virtualEngine.FetchGems(r.Context(), virt, path) + } else { + loc, err = h.virtualEngine.GemsRedirect(r.Context(), virt, path, r.URL.RawQuery, proxyBaseURL) + } + switch { + case errors.Is(err, virtual.ErrNotFound): + gemsText(w, http.StatusNotFound, "not found") + case errors.Is(err, virtual.ErrBadPath): + gemsText(w, http.StatusBadRequest, err.Error()) + case err != nil: + slog.Error("virtual gems fetch failed", "virtual", virt.Name, "path", path, "error", err) + gemsText(w, http.StatusBadGateway, "bad gateway") + case loc != "": + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Vary", "Host, X-Forwarded-Proto") + http.Redirect(w, r, loc, http.StatusFound) + default: + w.Header().Set("X-Artifact-Source", "virtual") + gemsText(w, http.StatusOK, string(body)) + } +} diff --git a/internal/api/v1/npm.go b/internal/api/v1/npm.go new file mode 100644 index 0000000..5927f7f --- /dev/null +++ b/internal/api/v1/npm.go @@ -0,0 +1,334 @@ +package v1 + +import ( + "bytes" + "context" + "crypto/sha1" + "crypto/sha512" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "regexp" + "strings" + + "github.com/go-chi/chi/v5" + + "git.unkin.net/unkin/artifactapi/internal/database" + "git.unkin.net/unkin/artifactapi/internal/storage" + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +// Local npm repos speak the npm registry publish/read protocol. Per package: +// /-/-.tgz tarball (immutable) +// /-/-.json version manifest (immutable) +// /-/dist-tags/ version string (mutable) + +var ( + npmNameRe = regexp.MustCompile(`^(@[a-z0-9][a-z0-9._~-]*/)?[a-z0-9][a-z0-9._~-]*$`) + npmRefRe = regexp.MustCompile(`^[0-9A-Za-z][0-9A-Za-z.+_-]*$`) + npmSemverRe = regexp.MustCompile(`^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$`) + + npmMaxPublishBytes int64 = 256 << 20 +) + +func (h *ProxyHandler) localNPMRemote(r *http.Request, name string) (*models.Remote, bool) { + remote, err := h.db.GetRemote(r.Context(), name) + if err != nil { + return nil, false + } + return remote, remote.RepoType == models.RepoTypeLocal && remote.PackageType == models.PackageNPM +} + +// npmPackageName returns the package a packument/publish path addresses, or "" +// when the path is something else (e.g. a tarball under /-/). +func npmPackageName(raw string) string { + p, err := url.PathUnescape(raw) + if err != nil { + return "" + } + p = strings.TrimSuffix(p, "/") + if !npmNameRe.MatchString(p) { + return "" + } + return p +} + +type npmRelease struct { + version string + manifest []byte + tarball []byte + tags []string +} + +func parseNPMPublish(body io.Reader, pkg string) (*npmRelease, error) { + var doc struct { + Name string `json:"name"` + DistTags map[string]string `json:"dist-tags"` + Versions map[string]json.RawMessage `json:"versions"` + Attachments map[string]struct { + Data string `json:"data"` + } `json:"_attachments"` + } + if err := json.NewDecoder(body).Decode(&doc); err != nil { + return nil, fmt.Errorf("invalid publish body: %w", err) + } + if doc.Name != pkg { + return nil, fmt.Errorf("body name %q does not match %q", doc.Name, pkg) + } + if len(doc.Versions) != 1 || len(doc.Attachments) != 1 { + return nil, errors.New("publish must carry exactly one version and one tarball") + } + + rel := &npmRelease{} + for v, m := range doc.Versions { + rel.version, rel.manifest = v, m + } + if !npmSemverRe.MatchString(rel.version) { + return nil, fmt.Errorf("invalid version %q", rel.version) + } + var meta struct { + Name string `json:"name"` + Version string `json:"version"` + Dist struct { + Integrity string `json:"integrity"` + Shasum string `json:"shasum"` + } `json:"dist"` + } + if err := json.Unmarshal(rel.manifest, &meta); err != nil { + return nil, fmt.Errorf("invalid version manifest: %w", err) + } + if meta.Name != pkg || meta.Version != rel.version { + return nil, errors.New("version manifest name/version mismatch") + } + + for _, a := range doc.Attachments { + tgz, err := base64.StdEncoding.DecodeString(a.Data) + if err != nil { + return nil, fmt.Errorf("invalid tarball encoding: %w", err) + } + rel.tarball = tgz + } + if err := verifyNPMDist(rel.tarball, meta.Dist.Integrity, meta.Dist.Shasum); err != nil { + return nil, err + } + + for tag, v := range doc.DistTags { + if !npmRefRe.MatchString(tag) || v != rel.version { + return nil, fmt.Errorf("dist-tag %q must point at the published version", tag) + } + rel.tags = append(rel.tags, tag) + } + return rel, nil +} + +func verifyNPMDist(tgz []byte, integrity, shasum string) error { + if b64, ok := strings.CutPrefix(integrity, "sha512-"); ok { + sum := sha512.Sum512(tgz) + if base64.StdEncoding.EncodeToString(sum[:]) != b64 { + return errors.New("tarball does not match dist.integrity") + } + return nil + } + if shasum != "" { + sum := sha1.Sum(tgz) + if hex.EncodeToString(sum[:]) != shasum { + return errors.New("tarball does not match dist.shasum") + } + return nil + } + return errors.New("version manifest carries no sha512 integrity or shasum") +} + +func npmError(w http.ResponseWriter, status int, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]string{"error": msg}) +} + +func (h *ProxyHandler) handleLocalPut(w http.ResponseWriter, r *http.Request) { + remote, ok := h.localNPMRemote(r, chi.URLParam(r, "localName")) + if !ok { + npmError(w, http.StatusMethodNotAllowed, "publish is only supported on local npm repositories") + return + } + pkg := npmPackageName(chi.URLParam(r, "*")) + if pkg == "" { + npmError(w, http.StatusBadRequest, "invalid package name") + return + } + rel, err := parseNPMPublish(http.MaxBytesReader(w, r.Body, npmMaxPublishBytes), pkg) + if err != nil { + var tooLarge *http.MaxBytesError + if errors.As(err, &tooLarge) { + npmError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("publish body exceeds %d bytes", npmMaxPublishBytes)) + return + } + npmError(w, http.StatusBadRequest, err.Error()) + return + } + + base := pkg[strings.LastIndex(pkg, "/")+1:] + stem := pkg + "/-/" + base + "-" + rel.version + // The manifest gates visibility and tags may only name visible versions, so + // the order is tarball, manifest, tags. A tarball left by a failed publish is + // accepted again only with identical bytes; an existing manifest always conflicts. + conflict := func() { + npmError(w, http.StatusConflict, fmt.Sprintf("%s@%s already exists; overwrites are not allowed", pkg, rel.version)) + } + ok, err = h.npmStoreImmutable(r.Context(), remote.Name, stem+".tgz", rel.tarball, "application/octet-stream") + if err != nil { + npmError(w, http.StatusInternalServerError, err.Error()) + return + } + if !ok { + conflict() + return + } + if _, err := h.npmStore(r.Context(), remote.Name, stem+".json", rel.manifest, "application/json", false); err != nil { + if errors.Is(err, database.ErrAlreadyExists) { + conflict() + return + } + npmError(w, http.StatusInternalServerError, err.Error()) + return + } + for _, tag := range rel.tags { + if _, err := h.npmStore(r.Context(), remote.Name, pkg+"/-/dist-tags/"+tag, []byte(rel.version), "text/plain", true); err != nil { + npmError(w, http.StatusInternalServerError, err.Error()) + return + } + } + + slog.Info("local npm package published", "repo", remote.Name, "package", pkg, "version", rel.version) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "id": pkg}) +} + +// npmStoreImmutable reports false when path already holds different bytes. +func (h *ProxyHandler) npmStoreImmutable(ctx context.Context, repo, path string, data []byte, contentType string) (bool, error) { + hash, err := h.npmStore(ctx, repo, path, data, contentType, false) + if !errors.Is(err, database.ErrAlreadyExists) { + return err == nil, err + } + existing, err := h.db.GetLocalFile(ctx, repo, path) + if err != nil || existing == nil { + return false, fmt.Errorf("read existing %s: %v", path, err) + } + return existing.ContentHash == hash, nil +} + +func (h *ProxyHandler) npmStore(ctx context.Context, repo, path string, data []byte, contentType string, mutable bool) (string, error) { + result, err := h.cas.Store(ctx, bytes.NewReader(data), contentType) + if err != nil { + return "", err + } + if err := h.db.UpsertBlob(ctx, result.ContentHash, result.S3Key, result.SizeBytes, contentType); err != nil { + return "", err + } + if mutable { + return result.ContentHash, h.db.UpsertLocalFile(ctx, repo, path, result.ContentHash) + } + return result.ContentHash, h.db.CreateLocalFile(ctx, repo, path, result.ContentHash) +} + +func (h *ProxyHandler) readLocalBlob(ctx context.Context, contentHash string) ([]byte, error) { + reader, _, err := h.store.Download(ctx, storage.BlobKey(strings.TrimPrefix(contentHash, "sha256:"))) + if err != nil { + return nil, err + } + defer reader.Close() + return io.ReadAll(reader) +} + +func (h *ProxyHandler) serveNPMPackument(w http.ResponseWriter, r *http.Request, remote *models.Remote, pkg string) { + files, err := h.db.ListLocalFilesByPrefix(r.Context(), remote.Name, pkg+"/-/") + if err != nil { + npmError(w, http.StatusInternalServerError, err.Error()) + return + } + base, ok := requestBaseURL(w, r) + if !ok { + return + } + read := func(hash string) ([]byte, error) { return h.readLocalBlob(r.Context(), hash) } + tarballBase := base + "/api/v1/local/" + remote.Name + "/" + doc, err := buildNPMPackument(pkg, files, read, tarballBase) + if err != nil { + npmError(w, http.StatusInternalServerError, err.Error()) + return + } + if doc == nil { + npmError(w, http.StatusNotFound, "not found") + return + } + setNPMPerHostHeaders(w) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-Artifact-Source", "local") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(doc) +} + +// setNPMPerHostHeaders keeps shared caches off packuments whose tarball URLs +// are built from the request host and Accept (abbreviated vs full). +func setNPMPerHostHeaders(w http.ResponseWriter) { + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Vary", "Host, X-Forwarded-Proto, Accept") +} + +// buildNPMPackument returns nil when pkg has no published versions. +func buildNPMPackument(pkg string, files []database.LocalFile, read func(hash string) ([]byte, error), tarballBase string) (map[string]any, error) { + prefix := pkg + "/-/" + versions := map[string]any{} + tags := map[string]string{} + for _, f := range files { + // LIKE treats '_' as a wildcard, so the prefix match can over-select. + rest, ok := strings.CutPrefix(f.FilePath, prefix) + if !ok { + continue + } + if tag, isTag := strings.CutPrefix(rest, "dist-tags/"); isTag { + v, err := read(f.ContentHash) + if err != nil { + return nil, err + } + tags[tag] = string(v) + continue + } + stem, isManifest := strings.CutSuffix(rest, ".json") + if !isManifest { + continue + } + raw, err := read(f.ContentHash) + if err != nil { + return nil, err + } + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + return nil, fmt.Errorf("manifest %s: %w", f.FilePath, err) + } + dist, _ := m["dist"].(map[string]any) + if dist == nil { + dist = map[string]any{} + m["dist"] = dist + } + dist["tarball"] = tarballBase + prefix + stem + ".tgz" + v, _ := m["version"].(string) + versions[v] = m + } + if len(versions) == 0 { + return nil, nil + } + for tag, v := range tags { + if _, ok := versions[v]; !ok { + delete(tags, tag) + } + } + return map[string]any{"_id": pkg, "name": pkg, "dist-tags": tags, "versions": versions}, nil +} diff --git a/internal/api/v1/npm_handler_test.go b/internal/api/v1/npm_handler_test.go new file mode 100644 index 0000000..695efe9 --- /dev/null +++ b/internal/api/v1/npm_handler_test.go @@ -0,0 +1,347 @@ +package v1 + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "sync" + "testing" + "time" + + "github.com/go-chi/chi/v5" + + "git.unkin.net/unkin/artifactapi/internal/cache" + "git.unkin.net/unkin/artifactapi/internal/database" + "git.unkin.net/unkin/artifactapi/internal/proxy" + "git.unkin.net/unkin/artifactapi/internal/storage" + "git.unkin.net/unkin/artifactapi/internal/testsupport" + "git.unkin.net/unkin/artifactapi/internal/virtual" + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +var ( + testDSN string + testMinio testsupport.MinioConn + testRedis string +) + +func TestMain(m *testing.M) { + ctx := context.Background() + dsn, termPG, err := testsupport.StartPostgres(ctx) + if err != nil { + os.Exit(m.Run()) + } + conn, termMinio, err := testsupport.StartMinio(ctx) + if err != nil { + termPG() + os.Exit(m.Run()) + } + redisURL, termRedis, err := testsupport.StartRedis(ctx) + if err != nil { + termMinio() + termPG() + os.Exit(m.Run()) + } + testDSN, testMinio, testRedis = dsn, conn, redisURL + code := m.Run() + termRedis() + termMinio() + termPG() + os.Exit(code) +} + +func npmTestServer(t *testing.T) (*ProxyHandler, *httptest.Server) { + t.Helper() + if testDSN == "" { + t.Skip("Docker unavailable") + } + db, err := database.New(testDSN) + if err != nil { + t.Fatal(err) + } + t.Cleanup(db.Close) + var store *storage.S3 + for i := 0; i < 20; i++ { + if store, err = storage.NewS3(testMinio.Endpoint, testMinio.AccessKey, testMinio.SecretKey, "npm", false, ""); err == nil { + break + } + time.Sleep(500 * time.Millisecond) + } + if err != nil { + t.Fatal(err) + } + redis, err := cache.NewRedis(testRedis) + if err != nil { + t.Fatal(err) + } + h := NewProxyHandler(proxy.NewEngine(db, redis, store), nil, db, store, nil) + r := chi.NewRouter() + r.Mount("/api/v1", h.Routes()) + srv := httptest.NewServer(r) + t.Cleanup(srv.Close) + return h, srv +} + +func npmRepo(t *testing.T, h *ProxyHandler, pt models.PackageType) string { + t.Helper() + name := fmt.Sprintf("npm-%s-%d", pt, time.Now().UnixNano()) + if err := h.db.CreateRemote(context.Background(), &models.Remote{Name: name, PackageType: pt, RepoType: models.RepoTypeLocal}); err != nil { + t.Fatal(err) + } + return name +} + +func npmDo(t *testing.T, method, url, body string) (int, string) { + t.Helper() + req, _ := http.NewRequest(method, url, strings.NewReader(body)) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer func() { _ = resp.Body.Close() }() + b, _ := io.ReadAll(resp.Body) + return resp.StatusCode, string(b) +} + +func packumentVersions(t *testing.T, url string) map[string]any { + t.Helper() + code, body := npmDo(t, http.MethodGet, url, "") + if code != http.StatusOK { + t.Fatalf("GET %s = %d: %s", url, code, body) + } + var doc struct { + Versions map[string]any `json:"versions"` + } + if err := json.Unmarshal([]byte(body), &doc); err != nil { + t.Fatal(err) + } + return doc.Versions +} + +func packumentTags(t *testing.T, url string) map[string]string { + t.Helper() + _, body := npmDo(t, http.MethodGet, url, "") + var doc struct { + Tags map[string]string `json:"dist-tags"` + } + if err := json.Unmarshal([]byte(body), &doc); err != nil { + t.Fatal(err) + } + return doc.Tags +} + +func concurrentPuts(t *testing.T, url string, bodies ...string) []int { + t.Helper() + var wg sync.WaitGroup + codes := make([]int, len(bodies)) + for i, b := range bodies { + wg.Add(1) + go func() { + defer wg.Done() + codes[i], _ = npmDo(t, http.MethodPut, url, b) + }() + } + wg.Wait() + return codes +} + +func TestNPMPublishHandler(t *testing.T) { + h, srv := npmTestServer(t) + repo := npmRepo(t, h, models.PackageNPM) + base := srv.URL + "/api/v1/local/" + repo + "/" + + t.Run("scoped name via %2f", func(t *testing.T) { + body := publishBody(t, "@acme/widget", []byte("scoped tgz"), publishOpts{}) + if code, b := npmDo(t, http.MethodPut, base+"@acme%2fwidget", body); code != http.StatusCreated { + t.Fatalf("publish = %d: %s", code, b) + } + v := packumentVersions(t, base+"@acme%2fwidget") + dist := v["1.0.0"].(map[string]any)["dist"].(map[string]any) + tarball := dist["tarball"].(string) + if !strings.HasPrefix(tarball, base+"@acme/widget/-/widget-1.0.0.tgz") { + t.Fatalf("tarball = %s", tarball) + } + if code, b := npmDo(t, http.MethodGet, tarball, ""); code != http.StatusOK || b != "scoped tgz" { + t.Fatalf("tarball fetch = %d %q", code, b) + } + }) + + t.Run("republish", func(t *testing.T) { + if code, b := npmDo(t, http.MethodPut, base+"repub", publishBody(t, "repub", []byte("v1"), publishOpts{})); code != http.StatusCreated { + t.Fatalf("first publish = %d: %s", code, b) + } + if code, _ := npmDo(t, http.MethodPut, base+"repub", publishBody(t, "repub", []byte("different"), publishOpts{})); code != http.StatusConflict { + t.Errorf("different bytes = %d, want 409", code) + } + if code, b := npmDo(t, http.MethodPut, base+"repub", publishBody(t, "repub", []byte("v2"), publishOpts{version: "1.1.0"})); code != http.StatusCreated { + t.Fatalf("publish 1.1.0 = %d: %s", code, b) + } + if code, _ := npmDo(t, http.MethodPut, base+"repub", publishBody(t, "repub", []byte("v1"), publishOpts{})); code != http.StatusConflict { + t.Errorf("identical republish = %d, want 409", code) + } + if tags := packumentTags(t, base+"repub"); tags["latest"] != "1.1.0" { + t.Errorf("latest = %q, want 1.1.0", tags["latest"]) + } + }) + + t.Run("rejected republish leaves tags", func(t *testing.T) { + for _, v := range []string{"1.0.0", "1.1.0"} { + if code, b := npmDo(t, http.MethodPut, base+"rej", publishBody(t, "rej", []byte("tgz "+v), publishOpts{version: v})); code != http.StatusCreated { + t.Fatalf("publish %s = %d: %s", v, code, b) + } + } + if code, _ := npmDo(t, http.MethodPut, base+"rej", publishBody(t, "rej", []byte("tgz 1.0.0"), publishOpts{shasum: "x"})); code != http.StatusConflict { + t.Fatalf("re-PUT 1.0.0 = %d, want 409", code) + } + if tags := packumentTags(t, base+"rej"); tags["latest"] != "1.1.0" { + t.Errorf("latest = %q, want 1.1.0", tags["latest"]) + } + }) + + t.Run("mid-publish window", func(t *testing.T) { + if code, b := npmDo(t, http.MethodPut, base+"mid", publishBody(t, "mid", []byte("v1"), publishOpts{})); code != http.StatusCreated { + t.Fatalf("publish = %d: %s", code, b) + } + ctx := context.Background() + if _, err := h.npmStore(ctx, repo, "mid/-/mid-1.0.1.tgz", []byte("v2"), "application/octet-stream", false); err != nil { + t.Fatal(err) + } + if tags := packumentTags(t, base+"mid"); tags["latest"] != "1.0.0" { + t.Errorf("tarball-only 1.0.1: latest = %q", tags["latest"]) + } + if _, err := h.npmStore(ctx, repo, "mid/-/dist-tags/latest", []byte("1.0.1"), "text/plain", true); err != nil { + t.Fatal(err) + } + if tags := packumentTags(t, base+"mid"); tags["latest"] == "1.0.1" { + t.Error("latest points at invisible 1.0.1") + } + }) + + t.Run("concurrent identical", func(t *testing.T) { + body := publishBody(t, "same", []byte("same tgz"), publishOpts{}) + codes := concurrentPuts(t, base+"same", body, body) + if codes[0]+codes[1] != http.StatusCreated+http.StatusConflict || codes[0] == codes[1] { + t.Fatalf("codes = %v, want one 201 and one 409", codes) + } + if tags := packumentTags(t, base+"same"); len(tags) != 1 || tags["latest"] != "1.0.0" { + t.Errorf("tags = %v", tags) + } + }) + + t.Run("concurrent different manifests", func(t *testing.T) { + tgz := []byte("race tgz") + codes := concurrentPuts(t, base+"race", + publishBody(t, "race", tgz, publishOpts{shasum: "a", tags: map[string]string{"a": "1.0.0"}}), + publishBody(t, "race", tgz, publishOpts{shasum: "b", tags: map[string]string{"b": "1.0.0"}})) + won := map[int]string{} + for i, c := range codes { + won[c] = []string{"a", "b"}[i] + } + if len(won) != 2 || won[http.StatusCreated] == "" || won[http.StatusConflict] == "" { + t.Fatalf("codes = %v, want one 201 and one 409", codes) + } + v := packumentVersions(t, base+"race")["1.0.0"].(map[string]any) + if got := v["dist"].(map[string]any)["shasum"]; got != won[http.StatusCreated] { + t.Errorf("manifest shasum = %v, winner %s", got, won[http.StatusCreated]) + } + tags := packumentTags(t, base+"race") + if len(tags) != 1 || tags[won[http.StatusCreated]] != "1.0.0" { + t.Errorf("tags = %v, winner %s", tags, won[http.StatusCreated]) + } + }) + + t.Run("retry after tarball-only write", func(t *testing.T) { + tgz := []byte("half published") + if _, err := h.npmStore(context.Background(), repo, "retry/-/retry-1.0.0.tgz", tgz, "application/octet-stream", false); err != nil { + t.Fatal(err) + } + if code, _ := npmDo(t, http.MethodGet, base+"retry", ""); code != http.StatusNotFound { + t.Fatalf("tarball-only version visible: %d", code) + } + if code, _ := npmDo(t, http.MethodPut, base+"retry", publishBody(t, "retry", []byte("other bytes"), publishOpts{})); code != http.StatusConflict { + t.Errorf("retry with different bytes = %d, want 409", code) + } + if code, b := npmDo(t, http.MethodPut, base+"retry", publishBody(t, "retry", tgz, publishOpts{})); code != http.StatusCreated { + t.Fatalf("retry with identical bytes = %d: %s", code, b) + } + if _, ok := packumentVersions(t, base+"retry")["1.0.0"]; !ok { + t.Error("retried version missing from packument") + } + }) + + t.Run("concurrent versions", func(t *testing.T) { + codes := concurrentPuts(t, base+"conc", + publishBody(t, "conc", []byte("tgz 1.0.0"), publishOpts{version: "1.0.0"}), + publishBody(t, "conc", []byte("tgz 2.0.0"), publishOpts{version: "2.0.0"})) + if codes[0] != http.StatusCreated || codes[1] != http.StatusCreated { + t.Fatalf("codes = %v", codes) + } + v := packumentVersions(t, base+"conc") + if _, ok := v["1.0.0"]; !ok { + t.Error("1.0.0 missing") + } + if _, ok := v["2.0.0"]; !ok { + t.Error("2.0.0 missing") + } + }) + + t.Run("body too large", func(t *testing.T) { + old := npmMaxPublishBytes + npmMaxPublishBytes = 64 + defer func() { npmMaxPublishBytes = old }() + if code, _ := npmDo(t, http.MethodPut, base+"big", publishBody(t, "big", []byte("x"), publishOpts{})); code != http.StatusRequestEntityTooLarge { + t.Errorf("oversized body = %d, want 413", code) + } + }) + + t.Run("non-npm local", func(t *testing.T) { + generic := npmRepo(t, h, models.PackageGeneric) + if code, _ := npmDo(t, http.MethodPut, srv.URL+"/api/v1/local/"+generic+"/pkg", publishBody(t, "pkg", []byte("x"), publishOpts{})); code != http.StatusMethodNotAllowed { + t.Errorf("publish to generic local = %d, want 405", code) + } + }) +} + +func TestNPMPackumentPerHost(t *testing.T) { + h, srv := npmTestServer(t) + h.virtualEngine = virtual.NewEngine(h.db, nil) + h.virtualEngine.NPMLocal = h.LocalNPMPackument + repo := npmRepo(t, h, models.PackageNPM) + virt := "v" + repo + if err := h.db.CreateVirtual(context.Background(), &models.Virtual{Name: virt, PackageType: models.PackageNPM, Members: []string{repo}}); err != nil { + t.Fatal(err) + } + if code, b := npmDo(t, http.MethodPut, srv.URL+"/api/v1/local/"+repo+"/hostpkg", publishBody(t, "hostpkg", []byte("tgz"), publishOpts{})); code != http.StatusCreated { + t.Fatalf("publish = %d: %s", code, b) + } + routes := h.Routes() + get := func(path, host, proto string) *httptest.ResponseRecorder { + r := httptest.NewRequest(http.MethodGet, path, nil) + r.Host = host + if proto != "" { + r.Header.Set("X-Forwarded-Proto", proto) + } + w := httptest.NewRecorder() + routes.ServeHTTP(w, r) + return w + } + for _, path := range []string{"/local/" + repo + "/hostpkg", "/virtual/" + virt + "/hostpkg"} { + for _, host := range []string{"x@evil.com", "evil.example:"} { + if w := get(path, host, ""); w.Code != http.StatusBadRequest { + t.Errorf("%s Host %q = %d, want 400", path, host, w.Code) + } + } + w := get(path, "aa.example", "https") + if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"tarball":"https://aa.example/api/v1/`) { + t.Fatalf("%s = %d: %s", path, w.Code, w.Body) + } + if cc, vary := w.Header().Get("Cache-Control"), w.Header().Get("Vary"); cc != "no-store" || vary != "Host, X-Forwarded-Proto, Accept" { + t.Errorf("%s Cache-Control %q Vary %q", path, cc, vary) + } + } +} diff --git a/internal/api/v1/npm_test.go b/internal/api/v1/npm_test.go new file mode 100644 index 0000000..82cf676 --- /dev/null +++ b/internal/api/v1/npm_test.go @@ -0,0 +1,166 @@ +package v1 + +import ( + "crypto/sha1" + "crypto/sha512" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "strings" + "testing" + + "git.unkin.net/unkin/artifactapi/internal/database" +) + +func TestNPMPackageName(t *testing.T) { + tests := map[string]string{ + "left-pad": "left-pad", + "left-pad/": "left-pad", + "@acme%2fwidget": "@acme/widget", + "@acme%2Fwidget": "@acme/widget", + "@acme/widget": "@acme/widget", + "left-pad/-/left-pad.tgz": "", + "@acme/widget/-/w-1.tgz": "", + "a/b": "", + "../etc": "", + "Upper": "", + "": "", + } + for in, want := range tests { + if got := npmPackageName(in); got != want { + t.Errorf("npmPackageName(%q) = %q, want %q", in, got, want) + } + } +} + +type publishOpts struct { + bodyName, manifestName, version, integrity, shasum string + tags map[string]string + extraVersion bool +} + +func publishBody(t *testing.T, pkg string, tgz []byte, o publishOpts) string { + t.Helper() + sha := sha512.Sum512(tgz) + sh1 := sha1.Sum(tgz) + if o.bodyName == "" { + o.bodyName = pkg + } + if o.manifestName == "" { + o.manifestName = pkg + } + if o.version == "" { + o.version = "1.0.0" + } + if o.integrity == "" { + o.integrity = "sha512-" + base64.StdEncoding.EncodeToString(sha[:]) + } + if o.shasum == "" { + o.shasum = hex.EncodeToString(sh1[:]) + } + if o.tags == nil { + o.tags = map[string]string{"latest": o.version} + } + versions := map[string]any{o.version: map[string]any{ + "name": o.manifestName, "version": o.version, "main": "index.js", + "dist": map[string]any{"integrity": o.integrity, "shasum": o.shasum, "tarball": "http://elsewhere/x.tgz"}, + }} + if o.extraVersion { + versions["2.0.0"] = map[string]any{"name": pkg, "version": "2.0.0"} + } + b, _ := json.Marshal(map[string]any{ + "_id": pkg, "name": o.bodyName, "dist-tags": o.tags, "versions": versions, + "_attachments": map[string]any{pkg + "-" + o.version + ".tgz": map[string]any{ + "content_type": "application/octet-stream", "data": base64.StdEncoding.EncodeToString(tgz), "length": len(tgz), + }}, + }) + return string(b) +} + +func TestParseNPMPublish(t *testing.T) { + tgz := []byte("fake tarball bytes") + + rel, err := parseNPMPublish(strings.NewReader(publishBody(t, "@acme/widget", tgz, publishOpts{tags: map[string]string{"latest": "1.0.0", "beta": "1.0.0"}})), "@acme/widget") + if err != nil { + t.Fatalf("valid scoped publish rejected: %v", err) + } + if rel.version != "1.0.0" || string(rel.tarball) != string(tgz) || len(rel.tags) != 2 { + t.Fatalf("unexpected release: %+v", rel) + } + + if _, err := parseNPMPublish(strings.NewReader(publishBody(t, "left-pad", tgz, publishOpts{integrity: "none", shasum: hex.EncodeToString(make([]byte, 20))})), "left-pad"); err == nil { + t.Fatal("shasum fallback should reject a mismatched tarball") + } + if _, err := parseNPMPublish(strings.NewReader(publishBody(t, "left-pad", tgz, publishOpts{integrity: "none"})), "left-pad"); err != nil { + t.Fatalf("matching shasum fallback rejected: %v", err) + } + + bad := map[string]publishOpts{ + "body name mismatch": {bodyName: "other"}, + "manifest name mismatch": {manifestName: "other"}, + "integrity mismatch": {integrity: "sha512-AAAA"}, + "path-like version": {version: "../1.0.0"}, + "non-semver version": {version: "1..2"}, + "leading-zero version": {version: "01.0.0"}, + "tag at another version": {tags: map[string]string{"latest": "9.9.9"}}, + "path-like tag": {tags: map[string]string{"../x": "1.0.0"}}, + "multiple versions at once": {extraVersion: true}, + } + for name, o := range bad { + if _, err := parseNPMPublish(strings.NewReader(publishBody(t, "left-pad", tgz, o)), "left-pad"); err == nil { + t.Errorf("%s: expected rejection", name) + } + } + if _, err := parseNPMPublish(strings.NewReader(`{"name":"left-pad","versions":{}}`), "left-pad"); err == nil { + t.Error("deprecate-style body without attachments should be rejected") + } +} + +func TestBuildNPMPackument(t *testing.T) { + blobs := map[string]string{ + "h1": `{"name":"my_pkg","version":"1.0.0","dist":{"shasum":"aa","tarball":"http://elsewhere/x.tgz"}}`, + "h2": `{"name":"my_pkg","version":"1.1.0"}`, + "h3": "1.1.0", + "h4": "2.0.0", + "hx": `{"name":"myXpkg","version":"6.6.6"}`, + } + read := func(h string) ([]byte, error) { + b, ok := blobs[h] + if !ok { + return nil, errors.New("missing blob") + } + return []byte(b), nil + } + files := []database.LocalFile{ + {FilePath: "my_pkg/-/dist-tags/latest", ContentHash: "h3"}, + {FilePath: "my_pkg/-/dist-tags/next", ContentHash: "h4"}, + {FilePath: "my_pkg/-/my_pkg-1.0.0.json", ContentHash: "h1"}, + {FilePath: "my_pkg/-/my_pkg-1.0.0.tgz", ContentHash: "nope"}, + {FilePath: "my_pkg/-/my_pkg-1.1.0.json", ContentHash: "h2"}, + {FilePath: "myXpkg/-/myXpkg-6.6.6.json", ContentHash: "hx"}, + } + + doc, err := buildNPMPackument("my_pkg", files, read, "http://reg/api/v1/local/npm/") + if err != nil { + t.Fatal(err) + } + if tags := doc["dist-tags"].(map[string]string); tags["latest"] != "1.1.0" || len(tags) != 1 { + t.Errorf("dist-tags = %v", tags) + } + versions := doc["versions"].(map[string]any) + if len(versions) != 2 { + t.Fatalf("versions = %v (LIKE over-match must be dropped)", versions) + } + v1 := versions["1.0.0"].(map[string]any)["dist"].(map[string]any) + if v1["tarball"] != "http://reg/api/v1/local/npm/my_pkg/-/my_pkg-1.0.0.tgz" || v1["shasum"] != "aa" { + t.Errorf("1.0.0 dist = %v", v1) + } + if versions["1.1.0"].(map[string]any)["dist"].(map[string]any)["tarball"] != "http://reg/api/v1/local/npm/my_pkg/-/my_pkg-1.1.0.tgz" { + t.Error("1.1.0 missing rewritten tarball") + } + + if doc, err := buildNPMPackument("absent", files, read, "x/"); err != nil || doc != nil { + t.Errorf("absent package: doc=%v err=%v", doc, err) + } +} diff --git a/internal/api/v1/npm_virtual.go b/internal/api/v1/npm_virtual.go new file mode 100644 index 0000000..7ac006b --- /dev/null +++ b/internal/api/v1/npm_virtual.go @@ -0,0 +1,26 @@ +package v1 + +import ( + "context" + "encoding/json" + + "git.unkin.net/unkin/artifactapi/internal/virtual" +) + +// LocalNPMPackument serves a local npm repo's packument to npm virtuals, with +// tarball paths relative to the repo. +func (h *ProxyHandler) LocalNPMPackument(ctx context.Context, repo, pkg string) ([]byte, error) { + files, err := h.db.ListLocalFilesByPrefix(ctx, repo, pkg+"/-/") + if err != nil { + return nil, err + } + read := func(hash string) ([]byte, error) { return h.readLocalBlob(ctx, hash) } + doc, err := buildNPMPackument(pkg, files, read, "") + if err != nil { + return nil, err + } + if doc == nil { + return nil, virtual.ErrNotFound + } + return json.Marshal(doc) +} diff --git a/internal/api/v1/proxy.go b/internal/api/v1/proxy.go index 8100658..486394b 100644 --- a/internal/api/v1/proxy.go +++ b/internal/api/v1/proxy.go @@ -6,8 +6,11 @@ import ( "fmt" "io" "log/slog" + "net" "net/http" "net/url" + "strconv" + "strings" "github.com/go-chi/chi/v5" @@ -17,6 +20,7 @@ import ( "git.unkin.net/unkin/artifactapi/internal/proxy" "git.unkin.net/unkin/artifactapi/internal/storage" "git.unkin.net/unkin/artifactapi/internal/virtual" + "git.unkin.net/unkin/artifactapi/pkg/models" ) type ProxyHandler struct { @@ -42,8 +46,14 @@ func NewProxyHandler(engine *proxy.Engine, virtualEngine *virtual.Engine, db *da func (h *ProxyHandler) Routes() chi.Router { r := chi.NewRouter() r.Get("/remote/{remoteName}/*", h.handleProxy) + r.Head("/remote/{remoteName}/*", h.handleProxyHead) r.Get("/local/{localName}/*", h.handleLocal) + r.Head("/local/{localName}/*", h.handleLocal) + r.Put("/local/{localName}/*", h.handleLocalPut) + r.Post("/local/{localName}/*", h.handleLocalPost) + r.Delete("/local/{localName}/*", h.handleLocalDelete) r.Get("/virtual/{virtualName}/*", h.handleVirtual) + r.Head("/virtual/{virtualName}/*", h.handleVirtual) return r } @@ -69,31 +79,43 @@ func (h *ProxyHandler) handleDockerPing(w http.ResponseWriter, r *http.Request) w.WriteHeader(http.StatusOK) } -func (h *ProxyHandler) handleProxy(w http.ResponseWriter, r *http.Request) { +// remoteFor resolves the remote and provider named in the request, writing the +// error response itself when either is missing. +func (h *ProxyHandler) remoteFor(w http.ResponseWriter, r *http.Request) (*models.Remote, provider.Provider, bool) { remoteName := chi.URLParam(r, "remoteName") - path := chi.URLParam(r, "*") - remote, err := h.db.GetRemote(r.Context(), remoteName) if err != nil { http.Error(w, fmt.Sprintf("remote %q not found", remoteName), http.StatusNotFound) - return + return nil, nil, false } - prov, err := provider.Get(remote.PackageType) if err != nil { http.Error(w, fmt.Sprintf("no provider for %q", remote.PackageType), http.StatusInternalServerError) + return nil, nil, false + } + return remote, prov, true +} + +// serveRemote lets metadata-only remotes (e.g. github_rpm) synthesize their own +// responses and redirect package downloads instead of proxying bytes. +func (h *ProxyHandler) serveRemote(w http.ResponseWriter, r *http.Request, remote *models.Remote, prov provider.Provider, path string) bool { + rs, ok := prov.(provider.RemoteServer) + return ok && rs.ServeRemote(w, r, *remote, path, fmt.Sprintf("%s://%s", Scheme(r), r.Host), h.db) +} + +func (h *ProxyHandler) handleProxy(w http.ResponseWriter, r *http.Request) { + remote, prov, ok := h.remoteFor(w, r) + if !ok { return } - - // Metadata-only remotes (e.g. github_rpm) synthesize their own responses and - // redirect package downloads to a backend remote instead of proxying bytes. - if rs, ok := prov.(provider.RemoteServer); ok { - proxyBaseURL := fmt.Sprintf("%s://%s", scheme(r), r.Host) - if rs.ServeRemote(w, r, *remote, path, proxyBaseURL, h.db) { - return - } + path := chi.URLParam(r, "*") + if h.serveRemote(w, r, remote, prov, path) { + return } + h.serveFetch(w, r, remote, prov, path) +} +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, forwardQuery(prov, path, r.URL.Query()), prov, r.Header) if err != nil { var proxyErr *proxy.ProxyError @@ -101,7 +123,7 @@ func (h *ProxyHandler) handleProxy(w http.ResponseWriter, r *http.Request) { http.Error(w, proxyErr.Message, proxyErr.Status) return } - slog.Error("proxy fetch failed", "remote", remoteName, "path", path, "error", err) + slog.Error("proxy fetch failed", "remote", remote.Name, "path", path, "error", err) http.Error(w, "bad gateway", http.StatusBadGateway) return } @@ -109,37 +131,43 @@ func (h *ProxyHandler) handleProxy(w http.ResponseWriter, r *http.Request) { var body io.Reader = result.Reader if sr, ok := prov.(provider.ServeRewriter); ok && prov.Classify(path) == provider.Mutable { - raw, err := io.ReadAll(result.Reader) - if err != nil { - http.Error(w, "bad gateway", http.StatusBadGateway) + raw, ok := rewriteServed(w, r, sr, *remote, result.Reader) + if !ok { return } - raw = sr.RewriteServed(raw, *remote, fmt.Sprintf("%s://%s", scheme(r), r.Host)) body, result.Size = bytes.NewReader(raw), int64(len(raw)) } w.Header().Set("Content-Type", result.ContentType) w.Header().Set("X-Artifact-Source", result.Source) if result.Size > 0 { + w.Header().Set("Content-Length", fmt.Sprintf("%d", result.Size)) w.Header().Set("X-Artifact-Size", fmt.Sprintf("%d", result.Size)) } w.WriteHeader(http.StatusOK) io.Copy(w, body) } -func (h *ProxyHandler) handleProxyHead(w http.ResponseWriter, r *http.Request) { - remoteName := chi.URLParam(r, "remoteName") - path := chi.URLParam(r, "*") +func rewritesMutable(prov provider.Provider, path string) bool { + _, sr := prov.(provider.ServeRewriter) + _, rr := prov.(provider.ResponseRewriter) + return (sr || rr) && prov.Classify(path) == provider.Mutable +} - remote, err := h.db.GetRemote(r.Context(), remoteName) - if err != nil { - http.Error(w, fmt.Sprintf("remote %q not found", remoteName), http.StatusNotFound) +// handleProxyHead answers HEAD like GET without a body. Responses a provider +// synthesizes or rewrites per request go through the GET path so their headers +// match; everything else is answered from store metadata or an upstream HEAD. +func (h *ProxyHandler) handleProxyHead(w http.ResponseWriter, r *http.Request) { + remote, prov, ok := h.remoteFor(w, r) + if !ok { return } - - prov, err := provider.Get(remote.PackageType) - if err != nil { - http.Error(w, fmt.Sprintf("no provider for %q", remote.PackageType), http.StatusInternalServerError) + path := chi.URLParam(r, "*") + if h.serveRemote(w, r, remote, prov, path) { + return + } + if rewritesMutable(prov, path) { + h.serveFetch(w, r, remote, prov, path) return } @@ -150,7 +178,7 @@ func (h *ProxyHandler) handleProxyHead(w http.ResponseWriter, r *http.Request) { http.Error(w, proxyErr.Message, proxyErr.Status) return } - slog.Error("proxy head failed", "remote", remoteName, "path", path, "error", err) + slog.Error("proxy head failed", "remote", remote.Name, "path", path, "error", err) http.Error(w, "bad gateway", http.StatusBadGateway) return } @@ -173,7 +201,15 @@ func (h *ProxyHandler) handleVirtual(w http.ResponseWriter, r *http.Request) { return } - proxyBaseURL := fmt.Sprintf("%s://%s", scheme(r), r.Host) + proxyBaseURL, ok := requestBaseURL(w, r) + if !ok { + return + } + + if virt.PackageType == models.PackageGems { + h.serveVirtualGems(w, r, *virt, path, proxyBaseURL) + return + } loc, ok, err := h.virtualEngine.MemberRedirect(r.Context(), *virt, path, r.URL.RawQuery, proxyBaseURL) if err != nil { @@ -185,7 +221,14 @@ func (h *ProxyHandler) handleVirtual(w http.ResponseWriter, r *http.Request) { return } - body, contentType, err := h.virtualEngine.Fetch(r.Context(), *virt, path, proxyBaseURL) + var body []byte + var contentType string + if virt.PackageType == models.PackageNPM { + abbreviated := strings.Contains(r.Header.Get("Accept"), virtual.NPMAbbreviatedType) + body, contentType, err = h.virtualEngine.FetchNPM(r.Context(), *virt, path, proxyBaseURL, abbreviated) + } else { + body, contentType, err = h.virtualEngine.Fetch(r.Context(), *virt, path, proxyBaseURL) + } if errors.Is(err, virtual.ErrNotFound) { http.Error(w, "not found", http.StatusNotFound) return @@ -196,6 +239,9 @@ func (h *ProxyHandler) handleVirtual(w http.ResponseWriter, r *http.Request) { return } + if virt.PackageType == models.PackageNPM { + setNPMPerHostHeaders(w) + } w.Header().Set("Content-Type", contentType) w.Header().Set("X-Artifact-Source", "virtual") w.WriteHeader(http.StatusOK) @@ -212,6 +258,23 @@ func (h *ProxyHandler) handleLocal(w http.ResponseWriter, r *http.Request) { return } + if remote.PackageType == models.PackageNPM { + if pkg := npmPackageName(path); pkg != "" { + h.serveNPMPackument(w, r, remote, pkg) + return + } + } + if remote.PackageType == models.PackageGems { + if isGemsIndexPath(path) { + h.serveLocalGemsIndex(w, r, remote, path) + return + } + if strings.HasPrefix(path, gemsIndexPrefix) || strings.HasPrefix(path, gemsYankedPrefix) { + http.Error(w, "not found", http.StatusNotFound) + return + } + } + prov, _ := provider.Get(remote.PackageType) if indexer, ok := prov.(provider.LocalIndexer); ok { if indexer.ServeLocalIndex(w, r, h.db, remote.Name, path) { @@ -250,11 +313,14 @@ func (h *ProxyHandler) serveLocalFile(w http.ResponseWriter, r *http.Request, re io.Copy(w, reader) } -func scheme(r *http.Request) string { +// Scheme returns the client-facing scheme: TLS, else the first +// X-Forwarded-Proto value when it is http or https, else http. +func Scheme(r *http.Request) string { if r.TLS != nil { return "https" } - if fwd := r.Header.Get("X-Forwarded-Proto"); fwd != "" { + fwd, _, _ := strings.Cut(r.Header.Get("X-Forwarded-Proto"), ",") + if fwd = strings.ToLower(strings.TrimSpace(fwd)); fwd == "https" { return fwd } return "http" @@ -278,3 +344,66 @@ func forwardQuery(prov provider.Provider, path string, query url.Values) string } 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) { + base, ok := requestBaseURL(w, r) + if !ok { + return nil, false + } + raw, err := io.ReadAll(src) + if err != nil { + http.Error(w, "bad gateway", http.StatusBadGateway) + return nil, false + } + raw = sr.RewriteServed(raw, remote, base) + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Vary", "Host, X-Forwarded-Proto") + w.Header().Set("Content-Length", strconv.Itoa(len(raw))) + return raw, true +} + +// requestBaseURL returns the client-facing scheme://host, answering 400 on an +// invalid Host. +func requestBaseURL(w http.ResponseWriter, r *http.Request) (string, bool) { + if !validHost(r.Host) { + http.Error(w, "invalid host header", http.StatusBadRequest) + return "", false + } + return Scheme(r) + "://" + r.Host, true +} + +// validHost accepts host[:port] where host is a DNS name or IP literal. +func validHost(hostport string) bool { + host, port := hostport, "" + if h, p, err := net.SplitHostPort(hostport); err == nil { + if p == "" { + return false + } + host, port = h, p + } else if strings.HasPrefix(hostport, "[") { + return strings.HasSuffix(hostport, "]") && net.ParseIP(hostport[1:len(hostport)-1]) != nil + } + if port != "" { + if n, err := strconv.Atoi(port); err != nil || n < 1 || n > 65535 { + return false + } + } + if net.ParseIP(host) != nil { + return true + } + if strings.HasPrefix(hostport, "[") { + return false + } + if host == "" || len(host) > 253 { + return false + } + for _, label := range strings.Split(strings.TrimSuffix(host, "."), ".") { + if label == "" || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' || + strings.Trim(strings.ToLower(label), "abcdefghijklmnopqrstuvwxyz0123456789-_") != "" { + return false + } + } + return true +} diff --git a/internal/api/v1/rewrite_test.go b/internal/api/v1/rewrite_test.go new file mode 100644 index 0000000..24fd732 --- /dev/null +++ b/internal/api/v1/rewrite_test.go @@ -0,0 +1,74 @@ +package v1 + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "git.unkin.net/unkin/artifactapi/internal/provider/nuget" + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +func TestValidHost(t *testing.T) { + for _, h := range []string{"aa.example", "aa.example:8443", "artifactapi.k8s.syd1.au.unkin.net", "10.0.0.1:80", "[::1]:8080", "[::1]", "::1", "localhost", "my_host.local"} { + if !validHost(h) { + t.Errorf("validHost(%q) = false, want true", h) + } + } + for _, h := range []string{"", "evil\".example", "a b", "aa.example:0", "aa.example:99999", "aa.example:x", "aa.example:", ":8080", "-bad.example", "a..b", "[::1", "[nope]:80", "a/b", "a