Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 841b9f1547 | |||
| ed6216d78b | |||
| eca686b7bb | |||
| ceae176089 | |||
| 78d24f7752 |
@@ -672,7 +672,7 @@ func TestMain(m *testing.M) {
|
|||||||
|
|
||||||
```hcl
|
```hcl
|
||||||
provider "artifactapi" {
|
provider "artifactapi" {
|
||||||
endpoint = "https://artifactapi.example.com"
|
endpoint = "https://artifactapi.k8s.syd1.au.unkin.net"
|
||||||
}
|
}
|
||||||
|
|
||||||
resource "artifactapi_remote" "terraform_registry" {
|
resource "artifactapi_remote" "terraform_registry" {
|
||||||
|
|||||||
@@ -20,26 +20,98 @@ API: `http://localhost:8000` | Frontend: `http://localhost:5173`
|
|||||||
|
|
||||||
## Package Types
|
## 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` |
|
||||||
|
| `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.
|
Providers classify paths automatically. Users only configure what to proxy and TTLs.
|
||||||
|
|
||||||
Classification precedence, TTLs and eviction for every remote: [docs/common/caching.md](docs/common/caching.md).
|
### `github_rpm` — GitHub releases as a yum repo (metadata-only, no precache)
|
||||||
|
|
||||||
Each type's paths, Terraform examples and local/virtual behaviour are in `docs/<type>/`:
|
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.
|
||||||
|
|
||||||
- `alpine`: [remote](docs/alpine/remote.md), [remote-github](docs/alpine/remote-github.md)
|
Each package's `<location>` points back at the remote, which **302-redirects**
|
||||||
- `cargo`: [remote](docs/cargo/remote.md)
|
the download to the `releases_remote` — an existing generic `github.com` remote
|
||||||
- `deb`: [remote](docs/deb/remote.md), [remote-github](docs/deb/remote-github.md)
|
that streams the actual bytes. `dnf` follows the redirect transparently.
|
||||||
- `docker`: [remote](docs/docker/remote.md), [local](docs/docker/local.md)
|
|
||||||
- `generic`: [remote](docs/generic/remote.md)
|
#### Background syncer
|
||||||
- `gems`: [remote](docs/gems/remote.md), [local](docs/gems/local.md), [virtual](docs/gems/virtual.md)
|
|
||||||
- `goproxy`: [remote](docs/goproxy/remote.md)
|
A single process-wide **background syncer** keeps every `github_rpm` remote's
|
||||||
- `helm`: [remote](docs/helm/remote.md), [local](docs/helm/local.md), [virtual](docs/helm/virtual.md)
|
derived metadata current off the client request path:
|
||||||
- `npm`: [remote](docs/npm/remote.md), [local](docs/npm/local.md), [virtual](docs/npm/virtual.md)
|
|
||||||
- `nuget`: [remote](docs/nuget/remote.md)
|
- **Prime on create.** Creating a `github_rpm` remote enqueues a background prime
|
||||||
- `puppet`: [remote](docs/puppet/remote.md), [local](docs/puppet/local.md)
|
scan, so its metadata is derived right away without blocking the create call.
|
||||||
- `pypi`: [remote](docs/pypi/remote.md)
|
The first `dnf` request is served from cache. If a request arrives before the
|
||||||
- `rpm`: [remote](docs/rpm/remote.md), [remote-github](docs/rpm/remote-github.md)
|
prime lands, it returns a retryable `503` (with `Retry-After`) rather than
|
||||||
- `terraform`: [remote](docs/terraform/remote.md), [local](docs/terraform/local.md)
|
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.
|
||||||
|
|
||||||
### GitHub authentication
|
### GitHub authentication
|
||||||
|
|
||||||
@@ -108,9 +180,105 @@ GITHUB_APP_PRIVATE_KEY_PATH=/etc/artifactapi/github-app.pem
|
|||||||
|
|
||||||
## Terraform
|
## Terraform
|
||||||
|
|
||||||
Remotes and virtuals are managed by Terraform. Each package type has its own resource; see `docs/<type>/` for examples.
|
Remotes and virtuals are managed by Terraform. Each package type has its own resource:
|
||||||
|
|
||||||
Provider: [terraform-provider-artifactapi](https://git.unkin.net/unkin/terraform-provider-artifactapi)
|
```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/<repo>/<type>"
|
||||||
|
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.
|
||||||
|
|
||||||
## Access Control
|
## Access Control
|
||||||
|
|
||||||
@@ -130,8 +298,6 @@ No patterns + no blocklist = open proxy. Provider handles mutability classificat
|
|||||||
```
|
```
|
||||||
GET /api/v1/remote/{name}/{path} Proxy/cache artifact
|
GET /api/v1/remote/{name}/{path} Proxy/cache artifact
|
||||||
GET /api/v1/virtual/{name}/{path} Virtual repo (merged index)
|
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
|
GET /v2/{name}/{path} Docker Registry v2
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
# `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) |
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
# `alpine` remote
|
|
||||||
|
|
||||||
Shared classification, TTL and eviction rules: [caching](../common/caching.md).
|
|
||||||
|
|
||||||
| Type | Mutable (auto-detected) | Immutable (auto-detected) |
|
|
||||||
|---|---|---|
|
|
||||||
| `alpine` | `APKINDEX.tar.gz` | `.apk` |
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
# `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` |
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
# 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/<name>/`, 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/<name>/objects/<path>
|
|
||||||
curl -X DELETE https://artifactapi.example.com/api/v2/remotes/<name>/objects/<dir>/*
|
|
||||||
```
|
|
||||||
|
|
||||||
- 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 `<dir>/*` 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).
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
# `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) |
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
# `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` |
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
# `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",
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
# Local gems repository
|
|
||||||
|
|
||||||
A local `gems` repo is a RubyGems source: `gem push`, `gem yank`, `gem install`
|
|
||||||
and `bundle install` work against `/api/v1/local/<repo>`. `gem push` needs an
|
|
||||||
API key; any value works.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
GEM_HOST_API_KEY=unused gem push --host https://artifactapi.example.com/api/v1/local/<repo> mygem-1.0.0.gem
|
|
||||||
gem install mygem --source https://artifactapi.example.com/api/v1/local/<repo>/
|
|
||||||
gem yank mygem -v 1.0.0 --host https://artifactapi.example.com/api/v1/local/<repo>
|
|
||||||
```
|
|
||||||
|
|
||||||
```ruby
|
|
||||||
# Gemfile
|
|
||||||
source "https://artifactapi.example.com/api/v1/local/<repo>/"
|
|
||||||
gem "mygem"
|
|
||||||
```
|
|
||||||
|
|
||||||
The repo serves the compact index (`versions`, `info/<gem>`, `names`), `gems/*.gem`
|
|
||||||
and `quick/Marshal.4.8/*.gemspec.rz`. The legacy `specs.4.8.gz` indexes are not
|
|
||||||
served; current gem and bundler clients use the compact index.
|
|
||||||
|
|
||||||
Versions are immutable: pushing an existing name-version-platform is rejected
|
|
||||||
with 409. A yanked version disappears from the index and its files are removed.
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
# `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` |
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
# `gems` virtual
|
|
||||||
|
|
||||||
A gems virtual serves one RubyGems source over local and remote gems members:
|
|
||||||
`gem install` and `bundle install` work against `/api/v1/virtual/<name>/`.
|
|
||||||
|
|
||||||
- `info/<gem>` is the union of member lines; on a duplicate version-platform
|
|
||||||
the earlier member wins. A gem only one member has is served verbatim.
|
|
||||||
- `versions` regenerates the line (and md5) of every gem several members list
|
|
||||||
from its merged `info`; other gems' lines pass through. `names` is the union.
|
|
||||||
- `gems/*.gem` and `quick/Marshal.4.8/*.gemspec.rz` 302 to the member whose
|
|
||||||
`info` line won.
|
|
||||||
- A member error fails the request with 502; a member without the gem is
|
|
||||||
skipped. Merges are cached for 60s.
|
|
||||||
|
|
||||||
```hcl
|
|
||||||
resource "artifactapi_virtual" "gems" {
|
|
||||||
name = "gems"
|
|
||||||
package_type = "gems"
|
|
||||||
members = [artifactapi_local_gems.internal.name, artifactapi_remote_gems.rubygems.name]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```ruby
|
|
||||||
# Gemfile
|
|
||||||
source "https://artifactapi.example.com/api/v1/virtual/gems/"
|
|
||||||
```
|
|
||||||
|
|
||||||
Push to the local member directly; the virtual is read-only.
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
# `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$",
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
# `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` |
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
# Local helm repository
|
|
||||||
|
|
||||||
A local `helm` repo is a real chart repository: `PUT` a packaged chart to
|
|
||||||
`/api/v2/remotes/<repo>/files/<chart>-<version>.tgz` and `index.yaml` is
|
|
||||||
generated from each chart's `Chart.yaml` (with sha256 digests) at
|
|
||||||
`/api/v1/local/<repo>/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/<repo>/files/mychart-0.1.0.tgz
|
|
||||||
helm repo add <repo> https://artifactapi.example.com/api/v1/local/<repo>
|
|
||||||
```
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
# `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
|
|
||||||
}
|
|
||||||
```
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
# `helm` virtual
|
|
||||||
|
|
||||||
```hcl
|
|
||||||
resource "artifactapi_virtual" "helm" {
|
|
||||||
name = "helm"
|
|
||||||
package_type = "helm"
|
|
||||||
members = [artifactapi_remote_helm.jetstack.name]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
# 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/<repo>/`. npm requires an
|
|
||||||
`_authToken` for the registry before it will publish; any value works.
|
|
||||||
|
|
||||||
```ini
|
|
||||||
# .npmrc
|
|
||||||
registry=https://artifactapi.example.com/api/v1/local/<repo>/
|
|
||||||
//artifactapi.example.com/api/v1/local/<repo>/:_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.
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
# `npm` remote
|
|
||||||
|
|
||||||
Shared classification, TTL and eviction rules: [caching](../common/caching.md).
|
|
||||||
|
|
||||||
| Type | Mutable (auto-detected) | Immutable (auto-detected) |
|
|
||||||
|---|---|---|
|
|
||||||
| `npm` | package metadata | `.tgz` tarballs |
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
# `npm` virtual
|
|
||||||
|
|
||||||
An npm virtual serves one registry over local and remote npm members:
|
|
||||||
`npm install` and `npm view` work against `/api/v1/virtual/<name>/`.
|
|
||||||
|
|
||||||
- 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 (`<member>/<path>`), 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.
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
# `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` |
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
# Local puppet forge
|
|
||||||
|
|
||||||
A local `puppet` repo is a Forge: `PUT` a module tarball to
|
|
||||||
`/api/v2/remotes/<repo>/files/<owner>-<module>-<version>.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/<repo>`. 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/<repo>/files/unkin-hello-0.1.0.tar.gz
|
|
||||||
puppet module install unkin-hello \
|
|
||||||
--module_repository https://artifactapi.example.com/api/v1/local/<repo>
|
|
||||||
```
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
# `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` |
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
# `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` |
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
# `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 `<location>` 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.
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
# `rpm` remote
|
|
||||||
|
|
||||||
Shared classification, TTL and eviction rules: [caching](../common/caching.md).
|
|
||||||
|
|
||||||
| Type | Mutable (auto-detected) | Immutable (auto-detected) |
|
|
||||||
|---|---|---|
|
|
||||||
| `rpm` | `repomd.xml`, `repodata/*` | `.rpm` |
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
# 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/<repo>/<type>"
|
|
||||||
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.
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
# `terraform` remote
|
|
||||||
|
|
||||||
Shared classification, TTL and eviction rules: [caching](../common/caching.md).
|
|
||||||
|
|
||||||
| Type | Mutable (auto-detected) | Immutable (auto-detected) |
|
|
||||||
|---|---|---|
|
|
||||||
| `terraform` | `*/versions` | `*/download/*/*` |
|
|
||||||
+6
-13
@@ -22,21 +22,14 @@ already-running stack.
|
|||||||
## Coverage
|
## Coverage
|
||||||
|
|
||||||
- **Repository lifecycle** — add / change / delete for remote, local and virtual repos.
|
- **Repository lifecycle** — add / change / delete for remote, local and virtual repos.
|
||||||
- **Caching** — one immutable artifact per remote package type (one
|
- **Caching** — one immutable artifact per remote package type (generic, docker,
|
||||||
`caching_<type>_test.go` case each) proxied through the mock upstream: first fetch `X-Artifact-Source: remote`, second `cache`, bytes
|
helm, pypi, npm, rpm, alpine, puppet, terraform, goproxy, cargo, nuget) proxied through the
|
||||||
|
mock upstream: first fetch `X-Artifact-Source: remote`, second `cache`, bytes
|
||||||
verified against the origin fixture.
|
verified against the origin fixture.
|
||||||
- **Local uploads** — puppet (Forge v3 API via real `r10k` and `puppet module install`),
|
- **Local uploads** — generic (upload/download), pypi (wheel + generated `simple/`
|
||||||
generic (upload/download), pypi (wheel + generated `simple/`
|
index), rpm (real package + **automatic repodata** generation).
|
||||||
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
|
- **Virtual repositories** — pypi simple-index merge and helm `index.yaml` merge
|
||||||
across two members; npm packument merge over two locals via a stock `node:22`
|
across two members.
|
||||||
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
|
- **Mirrorlist** — an rpm remote with a `mirrorlist` of extra upstream mirrors
|
||||||
(pool = `base_url` + `mirrorlist`): round-robin distribution across both mirrors
|
(pool = `base_url` + `mirrorlist`): round-robin distribution across both mirrors
|
||||||
(constant-body `mockupstreama` / `mockupstreamb`), failover past a dead primary,
|
(constant-body `mockupstreama` / `mockupstreamb`), failover past a dead primary,
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
//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"})
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
//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"})
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
//go:build dockere2e
|
|
||||||
|
|
||||||
package e2edocker
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
cachingCases = append(cachingCases, cachingCase{"docker", "library/testimg/blobs/blobdata", "v2/library/testimg/blobs/blobdata"})
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
//go:build dockere2e
|
|
||||||
|
|
||||||
package e2edocker
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
cachingCases = append(cachingCases, cachingCase{"gems", "gems/mygem-1.0.0.gem", "gems/mygem-1.0.0.gem"})
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
//go:build dockere2e
|
|
||||||
|
|
||||||
package e2edocker
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
cachingCases = append(cachingCases, cachingCase{"generic", "blobs/hello.bin", "blobs/hello.bin"})
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
//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"})
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
//go:build dockere2e
|
|
||||||
|
|
||||||
package e2edocker
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
cachingCases = append(cachingCases, cachingCase{"helm", "charts/mychart-1.0.0.tgz", "charts/mychart-1.0.0.tgz"})
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
//go:build dockere2e
|
|
||||||
|
|
||||||
package e2edocker
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
cachingCases = append(cachingCases, cachingCase{"npm", "mypkg/-/mypkg-1.0.0.tgz", "mypkg/-/mypkg-1.0.0.tgz"})
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
//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"})
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
//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"})
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
//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"})
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
//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"})
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
//go:build dockere2e
|
|
||||||
|
|
||||||
package e2edocker
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
cachingCases = append(cachingCases, cachingCase{"terraform", "hashicorp/aws/download/pkg.zip", "v1/providers/hashicorp/aws/download/pkg.zip"})
|
|
||||||
}
|
|
||||||
+23
-13
@@ -9,23 +9,33 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
type cachingCase struct {
|
|
||||||
pkgType string
|
|
||||||
// path is the request path under /api/v1/remote/<name>/. 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_<type>_test.go file per package type.
|
|
||||||
var cachingCases []cachingCase
|
|
||||||
|
|
||||||
// TestCachingPerProvider proxies one immutable artifact for every remote
|
// TestCachingPerProvider proxies one immutable artifact for every remote
|
||||||
// package type through the mock upstream and asserts: first fetch is served
|
// 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.
|
// from the remote, the second from cache, and the bytes match the origin.
|
||||||
func TestCachingPerProvider(t *testing.T) {
|
func TestCachingPerProvider(t *testing.T) {
|
||||||
for _, tc := range cachingCases {
|
cases := []struct {
|
||||||
|
pkgType string
|
||||||
|
// path is the request path under /api/v1/remote/<name>/. 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"},
|
||||||
|
{"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 {
|
||||||
t.Run(tc.pkgType, func(t *testing.T) {
|
t.Run(tc.pkgType, func(t *testing.T) {
|
||||||
name := "cache-" + tc.pkgType
|
name := "cache-" + tc.pkgType
|
||||||
createRepo(t, fmt.Sprintf(`{
|
createRepo(t, fmt.Sprintf(`{
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
mygem-1.0.0 e2e fixture
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
//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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
//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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
//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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
//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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
//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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
//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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,6 +14,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.unkin.net/unkin/artifactapi/internal/testsupport"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestVirtualPyPIMerge uploads different packages to two pypi locals and
|
// TestVirtualPyPIMerge uploads different packages to two pypi locals and
|
||||||
@@ -136,3 +138,104 @@ func TestVirtualRPMMerge(t *testing.T) {
|
|||||||
t.Fatalf("real dnf against rpm virtual failed: %v\n%s", err, out)
|
t.Fatalf("real dnf against rpm virtual failed: %v\n%s", err, out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestVirtualDebMerge merges two local deb repos carrying the same package: the
|
||||||
|
// first member wins the duplicate, and a real apt sees both members' packages
|
||||||
|
// and installs through the virtual.
|
||||||
|
func TestVirtualDebMerge(t *testing.T) {
|
||||||
|
createRepo(t, `{"name":"vdeb-a","package_type":"deb","repo_type":"local"}`)
|
||||||
|
createRepo(t, `{"name":"vdeb-b","package_type":"deb","repo_type":"local"}`)
|
||||||
|
defer deleteRepo(t, "vdeb-a")
|
||||||
|
defer deleteRepo(t, "vdeb-b")
|
||||||
|
|
||||||
|
uploadFile(t, "vdeb-a", "vdeb-one_1.0.0_amd64.deb", testsupport.MinimalDeb("vdeb-one", "1.0.0", "amd64"), "application/vnd.debian.binary-package")
|
||||||
|
uploadFile(t, "vdeb-b", "vdeb-one_1.0.0_amd64.deb", testsupport.MinimalDeb("vdeb-one", "1.0.0", "amd64"), "application/vnd.debian.binary-package")
|
||||||
|
uploadFile(t, "vdeb-b", "vdeb-two_2.0.0_amd64.deb", testsupport.MinimalDeb("vdeb-two", "2.0.0", "amd64"), "application/vnd.debian.binary-package")
|
||||||
|
|
||||||
|
// Local indexes regenerate asynchronously; merge only once vdeb-b lists both.
|
||||||
|
deadline := time.Now().Add(15 * time.Second)
|
||||||
|
for {
|
||||||
|
resp, body := doRequest(t, http.MethodGet, api("/api/v1/local/vdeb-b/Packages"), nil, "")
|
||||||
|
if resp.StatusCode == http.StatusOK && strings.Contains(string(body), "vdeb-two") || time.Now().After(deadline) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
createVirtual(t, `{"name":"vdeb","package_type":"deb","members":["vdeb-a","vdeb-b"]}`)
|
||||||
|
defer deleteVirtual(t, "vdeb")
|
||||||
|
|
||||||
|
_, body := doRequest(t, http.MethodGet, api("/api/v1/virtual/vdeb/Packages"), nil, "")
|
||||||
|
pk := string(body)
|
||||||
|
if strings.Count(pk, "Package: vdeb-one\n") != 1 || !strings.Contains(pk, "Filename: vdeb-a/pool/vdeb-one_1.0.0_amd64.deb") ||
|
||||||
|
!strings.Contains(pk, "Filename: vdeb-b/pool/vdeb-two_2.0.0_amd64.deb") {
|
||||||
|
t.Fatalf("merged Packages should hold vdeb-one from the first member and vdeb-two:\n%s", pk)
|
||||||
|
}
|
||||||
|
|
||||||
|
network := os.Getenv("COMPOSE_NETWORK")
|
||||||
|
internal := os.Getenv("ARTIFACTAPI_INTERNAL")
|
||||||
|
if network == "" || internal == "" {
|
||||||
|
t.Skip("COMPOSE_NETWORK/ARTIFACTAPI_INTERNAL not set; skipping real apt")
|
||||||
|
}
|
||||||
|
if _, err := exec.LookPath("docker"); err != nil {
|
||||||
|
t.Skip("docker not available on the test host")
|
||||||
|
}
|
||||||
|
script := "set -euo pipefail; " +
|
||||||
|
"rm -f /etc/apt/sources.list.d/*; " +
|
||||||
|
"echo \"deb [trusted=yes] $URL ./\" > /etc/apt/sources.list.d/vdeb.list; " +
|
||||||
|
"apt-get update; " +
|
||||||
|
"apt-cache policy vdeb-one vdeb-two | tee /dev/stderr | grep -c virtual/vdeb | grep -qx 2; " +
|
||||||
|
"apt-get install -y vdeb-one vdeb-two; " +
|
||||||
|
"dpkg -s vdeb-one vdeb-two"
|
||||||
|
out, err := exec.Command("docker", "run", "--rm", "--network", network,
|
||||||
|
"-e", "URL="+strings.TrimRight(internal, "/")+"/api/v1/virtual/vdeb/",
|
||||||
|
"debian:13", "bash", "-c", script).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("real apt against deb virtual failed: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVirtualDebSuite merges two proxied deb.debian.org remotes and drives a
|
||||||
|
// real apt through the virtual's dists/<suite> layout (by-hash indexes).
|
||||||
|
func TestVirtualDebSuite(t *testing.T) {
|
||||||
|
network := os.Getenv("COMPOSE_NETWORK")
|
||||||
|
internal := os.Getenv("ARTIFACTAPI_INTERNAL")
|
||||||
|
if network == "" || internal == "" {
|
||||||
|
t.Skip("COMPOSE_NETWORK/ARTIFACTAPI_INTERNAL not set; skipping real apt")
|
||||||
|
}
|
||||||
|
if _, err := exec.LookPath("docker"); err != nil {
|
||||||
|
t.Skip("docker not available on the test host")
|
||||||
|
}
|
||||||
|
for _, n := range []string{"vdebs-a", "vdebs-b"} {
|
||||||
|
createRepo(t, `{"name":"`+n+`","package_type":"deb","repo_type":"remote","base_url":"http://deb.debian.org/debian"}`)
|
||||||
|
defer deleteRepo(t, n)
|
||||||
|
}
|
||||||
|
createVirtual(t, `{"name":"vdebs","package_type":"deb","members":["vdebs-a","vdebs-b"]}`)
|
||||||
|
defer deleteVirtual(t, "vdebs")
|
||||||
|
|
||||||
|
resp, rel := doRequest(t, http.MethodGet, api("/api/v1/virtual/vdebs/dists/trixie-updates/Release"), nil, "")
|
||||||
|
if resp.StatusCode != http.StatusOK || !strings.Contains(string(rel), "Acquire-By-Hash: yes\n") ||
|
||||||
|
!strings.Contains(string(rel), " main/binary-amd64/Packages.gz\n") {
|
||||||
|
t.Fatalf("merged suite Release: status %d:\n%s", resp.StatusCode, rel)
|
||||||
|
}
|
||||||
|
|
||||||
|
script := "set -euo pipefail; " +
|
||||||
|
"rm -f /etc/apt/sources.list.d/*; " +
|
||||||
|
"echo \"deb [trusted=yes] $URL trixie-updates main\" > /etc/apt/sources.list.d/vdebs.list; " +
|
||||||
|
"apt-get update; " +
|
||||||
|
"idx=/tmp/avail; apt-cache dumpavail > $idx; " +
|
||||||
|
"grep -q '^Filename: vdebs-a/pool/' $idx; if grep -q '^Filename: vdebs-b/' $idx; then echo 'vdebs-b not deduped'; exit 1; fi; " +
|
||||||
|
"pkg=$(awk '/^Package:/{p=$2} /^Size:/{if(!m||$2<m){m=$2;b=p}} END{print b}' $idx); " +
|
||||||
|
"cd /tmp && apt-get download $pkg && ls -l /tmp/*.deb"
|
||||||
|
out, err := exec.Command("docker", "run", "--rm", "--network", network,
|
||||||
|
"-e", "URL="+strings.TrimRight(internal, "/")+"/api/v1/virtual/vdebs/",
|
||||||
|
"debian:13", "bash", "-c", script).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("real apt against deb virtual suite failed: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
t.Logf("%s", out)
|
||||||
|
resp, _ = doRequest(t, http.MethodGet, api("/api/v1/remote/vdebs-a/dists/trixie-updates/main/binary-amd64/Packages.xz"), nil, "")
|
||||||
|
if src := resp.Header.Get("X-Artifact-Source"); resp.StatusCode != http.StatusOK || src != "cache" {
|
||||||
|
t.Fatalf("member index the virtual merged should be cached: status %d source %q", resp.StatusCode, src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,182 +0,0 @@
|
|||||||
//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("<repomd/>"))
|
|
||||||
}))
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
//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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -13,7 +13,6 @@ import (
|
|||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
v1 "git.unkin.net/unkin/artifactapi/internal/api/v1"
|
|
||||||
"git.unkin.net/unkin/artifactapi/internal/database"
|
"git.unkin.net/unkin/artifactapi/internal/database"
|
||||||
tfprov "git.unkin.net/unkin/artifactapi/internal/provider/terraform"
|
tfprov "git.unkin.net/unkin/artifactapi/internal/provider/terraform"
|
||||||
"git.unkin.net/unkin/artifactapi/internal/tfsign"
|
"git.unkin.net/unkin/artifactapi/internal/tfsign"
|
||||||
@@ -291,5 +290,12 @@ func writeJSON(w http.ResponseWriter, v any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func baseURL(r *http.Request) string {
|
func baseURL(r *http.Request) string {
|
||||||
return v1.Scheme(r) + "://" + r.Host
|
scheme := "http"
|
||||||
|
if r.TLS != nil {
|
||||||
|
scheme = "https"
|
||||||
|
}
|
||||||
|
if fwd := r.Header.Get("X-Forwarded-Proto"); fwd != "" {
|
||||||
|
scheme = fwd
|
||||||
|
}
|
||||||
|
return scheme + "://" + r.Host
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,308 +0,0 @@
|
|||||||
package v1
|
|
||||||
|
|
||||||
import (
|
|
||||||
"cmp"
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log/slog"
|
|
||||||
"maps"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
|
|
||||||
"git.unkin.net/unkin/artifactapi/internal/database"
|
|
||||||
"git.unkin.net/unkin/artifactapi/internal/provider/gems"
|
|
||||||
"git.unkin.net/unkin/artifactapi/internal/virtual"
|
|
||||||
"git.unkin.net/unkin/artifactapi/pkg/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Local gems repos speak the RubyGems push/yank API and compact index. Per gem:
|
|
||||||
// gems/<full>.gem package (immutable)
|
|
||||||
// quick/Marshal.4.8/<full>.gemspec.rz marshalled spec (immutable)
|
|
||||||
// _index/<name>/<full> compact index /info line; gates visibility
|
|
||||||
// _yanked/<name>/<full> yank tombstone; blocks re-push of the version
|
|
||||||
|
|
||||||
var (
|
|
||||||
gemsMaxPushBytes int64 = 256 << 20
|
|
||||||
gemsLockWait = 30 * time.Second
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
gemsIndexPrefix = "_index/"
|
|
||||||
gemsYankedPrefix = "_yanked/"
|
|
||||||
)
|
|
||||||
|
|
||||||
func gemsText(w http.ResponseWriter, status int, msg string) {
|
|
||||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
||||||
w.WriteHeader(status)
|
|
||||||
_, _ = io.WriteString(w, msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *ProxyHandler) localGemsRemote(w http.ResponseWriter, r *http.Request) (*models.Remote, bool) {
|
|
||||||
remote, err := h.db.GetRemote(r.Context(), chi.URLParam(r, "localName"))
|
|
||||||
if err != nil || remote.RepoType != models.RepoTypeLocal || remote.PackageType != models.PackageGems {
|
|
||||||
gemsText(w, http.StatusMethodNotAllowed, "only supported on local gems repositories")
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
return remote, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// lockGemVersion serialises push and yank of one gem version across replicas.
|
|
||||||
func (h *ProxyHandler) lockGemVersion(w http.ResponseWriter, r *http.Request, repo, full string) (func(), bool) {
|
|
||||||
unlock, err := h.engine.Lock(r.Context(), repo, "_gemlock/"+full, gemsLockWait, 5*time.Minute)
|
|
||||||
if err != nil {
|
|
||||||
gemsText(w, http.StatusServiceUnavailable, full+" is being pushed or yanked, retry")
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
return unlock, true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *ProxyHandler) handleLocalPost(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if chi.URLParam(r, "*") != "api/v1/gems" {
|
|
||||||
gemsText(w, http.StatusNotFound, "not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
remote, ok := h.localGemsRemote(w, r)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
gem, err := io.ReadAll(http.MaxBytesReader(w, r.Body, gemsMaxPushBytes))
|
|
||||||
if err != nil {
|
|
||||||
gemsText(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("gem exceeds %d bytes", gemsMaxPushBytes))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
spec, err := gems.ParseGem(gem)
|
|
||||||
if err != nil {
|
|
||||||
gemsText(w, http.StatusBadRequest, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
quick, err := gems.QuickSpec(spec)
|
|
||||||
if err != nil {
|
|
||||||
gemsText(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
full := spec.FullName()
|
|
||||||
unlock, ok := h.lockGemVersion(w, r, remote.Name, full)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer unlock()
|
|
||||||
conflict := func() {
|
|
||||||
gemsText(w, http.StatusConflict, fmt.Sprintf("%s already exists; overwrites are not allowed", full))
|
|
||||||
}
|
|
||||||
index := gemsIndexPrefix + spec.Name + "/" + full
|
|
||||||
for _, p := range []string{gemsYankedPrefix + spec.Name + "/" + full, index} {
|
|
||||||
f, err := h.db.GetLocalFile(r.Context(), remote.Name, p)
|
|
||||||
if err != nil {
|
|
||||||
gemsText(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if f != nil && p == index {
|
|
||||||
conflict()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if f != nil {
|
|
||||||
gemsText(w, http.StatusConflict, fmt.Sprintf("%s was yanked; re-pushing a yanked version is not allowed", full))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// The version is not live, so on failure every path is an invisible leftover and safe to drop.
|
|
||||||
paths := []string{"gems/" + full + ".gem", "quick/Marshal.4.8/" + full + ".gemspec.rz", index}
|
|
||||||
fail := func(status int, msg string) {
|
|
||||||
for _, p := range paths {
|
|
||||||
if err := h.db.DeleteLocalFile(context.WithoutCancel(r.Context()), remote.Name, p); err != nil {
|
|
||||||
slog.Error("gem push cleanup failed", "repo", remote.Name, "path", p, "error", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
gemsText(w, status, msg)
|
|
||||||
}
|
|
||||||
// The index line gates visibility, so it is written last.
|
|
||||||
for _, f := range []struct {
|
|
||||||
path string
|
|
||||||
data []byte
|
|
||||||
}{
|
|
||||||
{paths[0], gem},
|
|
||||||
{paths[1], quick},
|
|
||||||
} {
|
|
||||||
ok, err := h.npmStoreImmutable(r.Context(), remote.Name, f.path, f.data, "application/octet-stream")
|
|
||||||
if err != nil {
|
|
||||||
fail(http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
fail(http.StatusConflict, fmt.Sprintf("%s already exists; overwrites are not allowed", full))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
line := gems.InfoLine(spec, gem)
|
|
||||||
if _, err := h.npmStore(r.Context(), remote.Name, index, []byte(line), "text/plain", false); err != nil {
|
|
||||||
fail(http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
slog.Info("local gem pushed", "repo", remote.Name, "gem", full)
|
|
||||||
gemsText(w, http.StatusOK, "Successfully registered gem: "+spec.Name+" ("+spec.Version+")")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *ProxyHandler) handleLocalDelete(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if chi.URLParam(r, "*") != "api/v1/gems/yank" {
|
|
||||||
gemsText(w, http.StatusNotFound, "not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
remote, ok := h.localGemsRemote(w, r)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 64<<10))
|
|
||||||
if err != nil {
|
|
||||||
gemsText(w, http.StatusRequestEntityTooLarge, "yank body too large")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
form, err := url.ParseQuery(string(body))
|
|
||||||
if err != nil {
|
|
||||||
gemsText(w, http.StatusBadRequest, "invalid form body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for k, v := range r.URL.Query() {
|
|
||||||
if form.Get(k) == "" {
|
|
||||||
form[k] = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
spec := &gems.Spec{Name: form.Get("gem_name"), Version: form.Get("version"), Platform: form.Get("platform")}
|
|
||||||
if spec.Platform == "" {
|
|
||||||
spec.Platform = "ruby"
|
|
||||||
}
|
|
||||||
if !gems.NameRe.MatchString(spec.Name) || !gems.VersionRe.MatchString(spec.Version) || !gems.PlatformRe.MatchString(spec.Platform) {
|
|
||||||
gemsText(w, http.StatusBadRequest, "invalid gem_name, version or platform")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
full := spec.FullName()
|
|
||||||
unlock, ok := h.lockGemVersion(w, r, remote.Name, full)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer unlock()
|
|
||||||
index := gemsIndexPrefix + spec.Name + "/" + full
|
|
||||||
existing, err := h.db.GetLocalFile(r.Context(), remote.Name, index)
|
|
||||||
if err != nil {
|
|
||||||
gemsText(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if existing == nil {
|
|
||||||
gemsText(w, http.StatusNotFound, "The version "+spec.Version+" does not exist")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// The tombstone keeps the yanked line and is written before any delete.
|
|
||||||
line, err := h.readLocalBlob(r.Context(), existing.ContentHash)
|
|
||||||
if err != nil {
|
|
||||||
gemsText(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if _, err := h.npmStore(r.Context(), remote.Name, gemsYankedPrefix+spec.Name+"/"+full, line, "text/plain", false); err != nil && !errors.Is(err, database.ErrAlreadyExists) {
|
|
||||||
gemsText(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, p := range []string{index, "gems/" + full + ".gem", "quick/Marshal.4.8/" + full + ".gemspec.rz"} {
|
|
||||||
if err := h.db.DeleteLocalFile(context.WithoutCancel(r.Context()), remote.Name, p); err != nil {
|
|
||||||
gemsText(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
slog.Info("local gem yanked", "repo", remote.Name, "gem", full)
|
|
||||||
gemsText(w, http.StatusOK, "Successfully deleted gem: "+spec.Name+" ("+spec.Version+")")
|
|
||||||
}
|
|
||||||
|
|
||||||
// isGemsIndexPath reports whether path is a compact index document.
|
|
||||||
func isGemsIndexPath(path string) bool {
|
|
||||||
return path == "versions" || path == "names" || strings.HasPrefix(path, "info/")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *ProxyHandler) serveLocalGemsIndex(w http.ResponseWriter, r *http.Request, remote *models.Remote, path string) {
|
|
||||||
body, err := h.LocalGemsIndex(r.Context(), remote.Name, path)
|
|
||||||
if errors.Is(err, virtual.ErrNotFound) {
|
|
||||||
gemsText(w, http.StatusNotFound, "not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
gemsText(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("X-Artifact-Source", "local")
|
|
||||||
gemsText(w, http.StatusOK, string(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
// LocalGemsIndex builds a local gems repo's versions, names or info/<gem>
|
|
||||||
// document, or returns virtual.ErrNotFound.
|
|
||||||
func (h *ProxyHandler) LocalGemsIndex(ctx context.Context, repo, path string) ([]byte, error) {
|
|
||||||
if name, ok := strings.CutPrefix(path, "info/"); ok {
|
|
||||||
if !gems.NameRe.MatchString(name) {
|
|
||||||
return nil, virtual.ErrNotFound
|
|
||||||
}
|
|
||||||
lines, err := h.localGemsLines(ctx, repo, gemsIndexPrefix+name+"/")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if len(lines[name]) == 0 {
|
|
||||||
return nil, virtual.ErrNotFound
|
|
||||||
}
|
|
||||||
return gems.InfoBody(lines[name]), nil
|
|
||||||
}
|
|
||||||
if path != "versions" && path != "names" {
|
|
||||||
return nil, virtual.ErrNotFound
|
|
||||||
}
|
|
||||||
lines, err := h.localGemsLines(ctx, repo, gemsIndexPrefix)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
names := slices.Sorted(maps.Keys(lines))
|
|
||||||
var b strings.Builder
|
|
||||||
if path == "names" {
|
|
||||||
b.WriteString("---\n")
|
|
||||||
for _, n := range names {
|
|
||||||
b.WriteString(n + "\n")
|
|
||||||
}
|
|
||||||
return []byte(b.String()), nil
|
|
||||||
}
|
|
||||||
b.WriteString(gems.VersionsHeader(time.Now()))
|
|
||||||
for _, n := range names {
|
|
||||||
b.WriteString(gems.VersionsLine(n, gems.InfoBody(lines[n])) + "\n")
|
|
||||||
}
|
|
||||||
return []byte(b.String()), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// localGemsLines returns /info lines per gem name in push order.
|
|
||||||
func (h *ProxyHandler) localGemsLines(ctx context.Context, repo, prefix string) (map[string][]string, error) {
|
|
||||||
files, err := h.db.ListLocalFilesByPrefix(ctx, repo, prefix)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
slices.SortFunc(files, func(a, b database.LocalFile) int { return cmp.Compare(a.ID, b.ID) })
|
|
||||||
out := map[string][]string{}
|
|
||||||
for _, f := range files {
|
|
||||||
// LIKE treats '_' as a wildcard, so the prefix match can over-select.
|
|
||||||
rest, ok := strings.CutPrefix(f.FilePath, prefix)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
name := strings.TrimPrefix(prefix, gemsIndexPrefix)
|
|
||||||
if name == "" {
|
|
||||||
name, _, _ = strings.Cut(rest, "/")
|
|
||||||
} else {
|
|
||||||
name = strings.TrimSuffix(name, "/")
|
|
||||||
}
|
|
||||||
line, err := h.readLocalBlob(ctx, f.ContentHash)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
out[name] = append(out[name], string(line))
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
@@ -1,254 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,334 +0,0 @@
|
|||||||
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:
|
|
||||||
// <pkg>/-/<base>-<version>.tgz tarball (immutable)
|
|
||||||
// <pkg>/-/<base>-<version>.json version manifest (immutable)
|
|
||||||
// <pkg>/-/dist-tags/<tag> 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 <pkg>/-/).
|
|
||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,347 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
+33
-162
@@ -6,10 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
@@ -19,7 +16,6 @@ import (
|
|||||||
"git.unkin.net/unkin/artifactapi/internal/proxy"
|
"git.unkin.net/unkin/artifactapi/internal/proxy"
|
||||||
"git.unkin.net/unkin/artifactapi/internal/storage"
|
"git.unkin.net/unkin/artifactapi/internal/storage"
|
||||||
"git.unkin.net/unkin/artifactapi/internal/virtual"
|
"git.unkin.net/unkin/artifactapi/internal/virtual"
|
||||||
"git.unkin.net/unkin/artifactapi/pkg/models"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type ProxyHandler struct {
|
type ProxyHandler struct {
|
||||||
@@ -45,14 +41,8 @@ func NewProxyHandler(engine *proxy.Engine, virtualEngine *virtual.Engine, db *da
|
|||||||
func (h *ProxyHandler) Routes() chi.Router {
|
func (h *ProxyHandler) Routes() chi.Router {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Get("/remote/{remoteName}/*", h.handleProxy)
|
r.Get("/remote/{remoteName}/*", h.handleProxy)
|
||||||
r.Head("/remote/{remoteName}/*", h.handleProxyHead)
|
|
||||||
r.Get("/local/{localName}/*", h.handleLocal)
|
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.Get("/virtual/{virtualName}/*", h.handleVirtual)
|
||||||
r.Head("/virtual/{virtualName}/*", h.handleVirtual)
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,43 +68,31 @@ func (h *ProxyHandler) handleDockerPing(w http.ResponseWriter, r *http.Request)
|
|||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
// remoteFor resolves the remote and provider named in the request, writing the
|
func (h *ProxyHandler) handleProxy(w http.ResponseWriter, r *http.Request) {
|
||||||
// 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")
|
remoteName := chi.URLParam(r, "remoteName")
|
||||||
|
path := chi.URLParam(r, "*")
|
||||||
|
|
||||||
remote, err := h.db.GetRemote(r.Context(), remoteName)
|
remote, err := h.db.GetRemote(r.Context(), remoteName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, fmt.Sprintf("remote %q not found", remoteName), http.StatusNotFound)
|
http.Error(w, fmt.Sprintf("remote %q not found", remoteName), http.StatusNotFound)
|
||||||
return nil, nil, false
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
prov, err := provider.Get(remote.PackageType)
|
prov, err := provider.Get(remote.PackageType)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, fmt.Sprintf("no provider for %q", remote.PackageType), http.StatusInternalServerError)
|
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
|
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) {
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
result, err := h.engine.Fetch(r.Context(), *remote, path, prov, r.Header)
|
result, err := h.engine.Fetch(r.Context(), *remote, path, prov, r.Header)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var proxyErr *proxy.ProxyError
|
var proxyErr *proxy.ProxyError
|
||||||
@@ -122,7 +100,7 @@ func (h *ProxyHandler) serveFetch(w http.ResponseWriter, r *http.Request, remote
|
|||||||
http.Error(w, proxyErr.Message, proxyErr.Status)
|
http.Error(w, proxyErr.Message, proxyErr.Status)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
slog.Error("proxy fetch failed", "remote", remote.Name, "path", path, "error", err)
|
slog.Error("proxy fetch failed", "remote", remoteName, "path", path, "error", err)
|
||||||
http.Error(w, "bad gateway", http.StatusBadGateway)
|
http.Error(w, "bad gateway", http.StatusBadGateway)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -130,43 +108,37 @@ func (h *ProxyHandler) serveFetch(w http.ResponseWriter, r *http.Request, remote
|
|||||||
|
|
||||||
var body io.Reader = result.Reader
|
var body io.Reader = result.Reader
|
||||||
if sr, ok := prov.(provider.ServeRewriter); ok && prov.Classify(path) == provider.Mutable {
|
if sr, ok := prov.(provider.ServeRewriter); ok && prov.Classify(path) == provider.Mutable {
|
||||||
raw, ok := rewriteServed(w, r, sr, *remote, result.Reader)
|
raw, err := io.ReadAll(result.Reader)
|
||||||
if !ok {
|
if err != nil {
|
||||||
|
http.Error(w, "bad gateway", http.StatusBadGateway)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
raw = sr.RewriteServed(raw, *remote, fmt.Sprintf("%s://%s", scheme(r), r.Host))
|
||||||
body, result.Size = bytes.NewReader(raw), int64(len(raw))
|
body, result.Size = bytes.NewReader(raw), int64(len(raw))
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", result.ContentType)
|
w.Header().Set("Content-Type", result.ContentType)
|
||||||
w.Header().Set("X-Artifact-Source", result.Source)
|
w.Header().Set("X-Artifact-Source", result.Source)
|
||||||
if result.Size > 0 {
|
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.Header().Set("X-Artifact-Size", fmt.Sprintf("%d", result.Size))
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
io.Copy(w, body)
|
io.Copy(w, body)
|
||||||
}
|
}
|
||||||
|
|
||||||
func rewritesMutable(prov provider.Provider, path string) bool {
|
|
||||||
_, sr := prov.(provider.ServeRewriter)
|
|
||||||
_, rr := prov.(provider.ResponseRewriter)
|
|
||||||
return (sr || rr) && prov.Classify(path) == provider.Mutable
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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) {
|
func (h *ProxyHandler) handleProxyHead(w http.ResponseWriter, r *http.Request) {
|
||||||
remote, prov, ok := h.remoteFor(w, r)
|
remoteName := chi.URLParam(r, "remoteName")
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
path := chi.URLParam(r, "*")
|
path := chi.URLParam(r, "*")
|
||||||
if h.serveRemote(w, r, remote, prov, path) {
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
if rewritesMutable(prov, path) {
|
|
||||||
h.serveFetch(w, r, remote, prov, path)
|
prov, err := provider.Get(remote.PackageType)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("no provider for %q", remote.PackageType), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,7 +149,7 @@ func (h *ProxyHandler) handleProxyHead(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, proxyErr.Message, proxyErr.Status)
|
http.Error(w, proxyErr.Message, proxyErr.Status)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
slog.Error("proxy head failed", "remote", remote.Name, "path", path, "error", err)
|
slog.Error("proxy head failed", "remote", remoteName, "path", path, "error", err)
|
||||||
http.Error(w, "bad gateway", http.StatusBadGateway)
|
http.Error(w, "bad gateway", http.StatusBadGateway)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -200,15 +172,7 @@ func (h *ProxyHandler) handleVirtual(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
proxyBaseURL, ok := requestBaseURL(w, r)
|
proxyBaseURL := fmt.Sprintf("%s://%s", scheme(r), r.Host)
|
||||||
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)
|
loc, ok, err := h.virtualEngine.MemberRedirect(r.Context(), *virt, path, r.URL.RawQuery, proxyBaseURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -220,14 +184,7 @@ func (h *ProxyHandler) handleVirtual(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var body []byte
|
body, contentType, err := h.virtualEngine.Fetch(r.Context(), *virt, path, proxyBaseURL)
|
||||||
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) {
|
if errors.Is(err, virtual.ErrNotFound) {
|
||||||
http.Error(w, "not found", http.StatusNotFound)
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
@@ -238,9 +195,6 @@ func (h *ProxyHandler) handleVirtual(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if virt.PackageType == models.PackageNPM {
|
|
||||||
setNPMPerHostHeaders(w)
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", contentType)
|
w.Header().Set("Content-Type", contentType)
|
||||||
w.Header().Set("X-Artifact-Source", "virtual")
|
w.Header().Set("X-Artifact-Source", "virtual")
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
@@ -257,23 +211,6 @@ func (h *ProxyHandler) handleLocal(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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)
|
prov, _ := provider.Get(remote.PackageType)
|
||||||
if indexer, ok := prov.(provider.LocalIndexer); ok {
|
if indexer, ok := prov.(provider.LocalIndexer); ok {
|
||||||
if indexer.ServeLocalIndex(w, r, h.db, remote.Name, path) {
|
if indexer.ServeLocalIndex(w, r, h.db, remote.Name, path) {
|
||||||
@@ -312,78 +249,12 @@ func (h *ProxyHandler) serveLocalFile(w http.ResponseWriter, r *http.Request, re
|
|||||||
io.Copy(w, reader)
|
io.Copy(w, reader)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scheme returns the client-facing scheme: TLS, else the first
|
func scheme(r *http.Request) string {
|
||||||
// X-Forwarded-Proto value when it is http or https, else http.
|
|
||||||
func Scheme(r *http.Request) string {
|
|
||||||
if r.TLS != nil {
|
if r.TLS != nil {
|
||||||
return "https"
|
return "https"
|
||||||
}
|
}
|
||||||
fwd, _, _ := strings.Cut(r.Header.Get("X-Forwarded-Proto"), ",")
|
if fwd := r.Header.Get("X-Forwarded-Proto"); fwd != "" {
|
||||||
if fwd = strings.ToLower(strings.TrimSpace(fwd)); fwd == "https" {
|
|
||||||
return fwd
|
return fwd
|
||||||
}
|
}
|
||||||
return "http"
|
return "http"
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
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<script>", strings.Repeat("a", 64) + ".example"} {
|
|
||||||
if validHost(h) {
|
|
||||||
t.Errorf("validHost(%q) = true, want false", h)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var nugetOrg = models.Remote{Name: "nuget-org", BaseURL: "https://api.nuget.org/v3/index.json"}
|
|
||||||
|
|
||||||
const serviceIndex = `{"resources":[{"@id":"https://api.nuget.org/v3-flatcontainer/"}]}`
|
|
||||||
|
|
||||||
func rewrite(t *testing.T, host, proto string) (*httptest.ResponseRecorder, string, bool) {
|
|
||||||
t.Helper()
|
|
||||||
r := httptest.NewRequest(http.MethodGet, "/api/v1/remote/nuget-org/index.json", nil)
|
|
||||||
r.Host = host
|
|
||||||
if proto != "" {
|
|
||||||
r.Header.Set("X-Forwarded-Proto", proto)
|
|
||||||
}
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
raw, ok := rewriteServed(w, r, &nuget.Provider{}, nugetOrg, strings.NewReader(serviceIndex))
|
|
||||||
return w, string(raw), ok
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRewriteServedHeaders(t *testing.T) {
|
|
||||||
w, body, ok := rewrite(t, "aa.example:8443", "https")
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("valid request rejected")
|
|
||||||
}
|
|
||||||
if want := `{"resources":[{"@id":"https://aa.example:8443/api/v1/remote/nuget-org/v3-flatcontainer/"}]}`; body != want {
|
|
||||||
t.Errorf("body = %s, want %s", body, want)
|
|
||||||
}
|
|
||||||
if got := w.Header().Get("Cache-Control"); got != "no-store" {
|
|
||||||
t.Errorf("Cache-Control = %q, want no-store", got)
|
|
||||||
}
|
|
||||||
if got := w.Header().Get("Vary"); got != "Host, X-Forwarded-Proto" {
|
|
||||||
t.Errorf("Vary = %q", got)
|
|
||||||
}
|
|
||||||
if got := w.Header().Get("Content-Length"); got != "91" || len(body) != 91 {
|
|
||||||
t.Errorf("Content-Length = %q, body len %d", got, len(body))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRewriteServedIgnoresBogusProto(t *testing.T) {
|
|
||||||
_, plain, _ := rewrite(t, "aa.example", "")
|
|
||||||
_, bogus, _ := rewrite(t, "aa.example", "javascript")
|
|
||||||
if bogus != plain || !strings.Contains(bogus, `"http://aa.example/`) {
|
|
||||||
t.Errorf("bogus X-Forwarded-Proto changed the body: %s", bogus)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRewriteServedRejectsInvalidHost(t *testing.T) {
|
|
||||||
w, _, ok := rewrite(t, `evil"},{"x":"`, "")
|
|
||||||
if ok || w.Code != http.StatusBadRequest {
|
|
||||||
t.Errorf("invalid host: ok=%v code=%d, want rejected with 400", ok, w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
package v1
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
|
|
||||||
"git.unkin.net/unkin/artifactapi/internal/provider"
|
|
||||||
"git.unkin.net/unkin/artifactapi/internal/provider/npm"
|
|
||||||
"git.unkin.net/unkin/artifactapi/internal/provider/nuget"
|
|
||||||
"git.unkin.net/unkin/artifactapi/internal/provider/rpm"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestRoutesRemoteHead(t *testing.T) {
|
|
||||||
r := (&ProxyHandler{}).Routes()
|
|
||||||
for _, m := range []string{http.MethodGet, http.MethodHead} {
|
|
||||||
if !r.Match(chi.NewRouteContext(), m, "/remote/myremote/some/path") {
|
|
||||||
t.Errorf("%s /remote/myremote/some/path not routed", m)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRewritesMutable(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
prov provider.Provider
|
|
||||||
path string
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{"npm packument", &npm.Provider{}, "left-pad", true},
|
|
||||||
{"npm tarball", &npm.Provider{}, "left-pad/-/left-pad-1.3.0.tgz", false},
|
|
||||||
{"nuget index", &nuget.Provider{}, "index.json", true},
|
|
||||||
{"rpm repodata", &rpm.Provider{}, "repodata/repomd.xml", false},
|
|
||||||
}
|
|
||||||
for _, tc := range tests {
|
|
||||||
if got := rewritesMutable(tc.prov, tc.path); got != tc.want {
|
|
||||||
t.Errorf("%s: rewritesMutable = %v, want %v", tc.name, got, tc.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,28 +7,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestScheme(t *testing.T) {
|
func TestScheme(t *testing.T) {
|
||||||
if got := Scheme(&http.Request{TLS: &tls.ConnectionState{}}); got != "https" {
|
if got := scheme(&http.Request{TLS: &tls.ConnectionState{}}); got != "https" {
|
||||||
t.Errorf("TLS request scheme = %q, want https", got)
|
t.Errorf("TLS request scheme = %q, want https", got)
|
||||||
}
|
}
|
||||||
r := &http.Request{Header: http.Header{"X-Forwarded-Proto": {"https"}}}
|
r := &http.Request{Header: http.Header{"X-Forwarded-Proto": {"https"}}}
|
||||||
if got := Scheme(r); got != "https" {
|
if got := scheme(r); got != "https" {
|
||||||
t.Errorf("X-Forwarded-Proto scheme = %q, want https", got)
|
t.Errorf("X-Forwarded-Proto scheme = %q, want https", got)
|
||||||
}
|
}
|
||||||
for fwd, want := range map[string]string{
|
if got := scheme(&http.Request{Header: http.Header{}}); got != "http" {
|
||||||
"https, http": "https",
|
|
||||||
"http, https": "http",
|
|
||||||
"HTTPS": "https",
|
|
||||||
" https ": "https",
|
|
||||||
"javascript": "http",
|
|
||||||
"ftp": "http",
|
|
||||||
"https;evil": "http",
|
|
||||||
} {
|
|
||||||
r := &http.Request{Header: http.Header{"X-Forwarded-Proto": {fwd}}}
|
|
||||||
if got := Scheme(r); got != want {
|
|
||||||
t.Errorf("X-Forwarded-Proto %q scheme = %q, want %s", fwd, got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if got := Scheme(&http.Request{Header: http.Header{}}); got != "http" {
|
|
||||||
t.Errorf("default scheme = %q, want http", got)
|
t.Errorf("default scheme = %q, want http", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-20
@@ -67,12 +67,8 @@ func (h *RemotesHandler) get(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, remote)
|
writeJSON(w, http.StatusOK, remote)
|
||||||
}
|
}
|
||||||
|
|
||||||
// defaultMutableTTL matches the remotes.mutable_ttl column default; a create
|
|
||||||
// that omits mutable_ttl gets it, an explicit 0 still means never expire.
|
|
||||||
const defaultMutableTTL = 3600
|
|
||||||
|
|
||||||
func (h *RemotesHandler) create(w http.ResponseWriter, r *http.Request) {
|
func (h *RemotesHandler) create(w http.ResponseWriter, r *http.Request) {
|
||||||
remote := models.Remote{MutableTTL: defaultMutableTTL}
|
var remote models.Remote
|
||||||
if err := json.NewDecoder(r.Body).Decode(&remote); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&remote); err != nil {
|
||||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
@@ -118,15 +114,7 @@ func (h *RemotesHandler) create(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (h *RemotesHandler) update(w http.ResponseWriter, r *http.Request) {
|
func (h *RemotesHandler) update(w http.ResponseWriter, r *http.Request) {
|
||||||
name := chi.URLParam(r, "name")
|
name := chi.URLParam(r, "name")
|
||||||
// Capture the current backend before the update so we can tell whether the
|
|
||||||
// remote's base_url (its upstream) changed. A read failure just means we
|
|
||||||
// skip the freshness flush; it must not block the update.
|
|
||||||
existing, err := h.db.GetRemote(r.Context(), name)
|
|
||||||
oldKnown := err == nil
|
|
||||||
var remote models.Remote
|
var remote models.Remote
|
||||||
if oldKnown {
|
|
||||||
remote.MutableTTL = existing.MutableTTL
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&remote); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&remote); err != nil {
|
||||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
@@ -144,6 +132,13 @@ func (h *RemotesHandler) update(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Capture the current backend before the update so we can tell whether the
|
||||||
|
// remote's base_url (its upstream) changed. A read failure just means we
|
||||||
|
// skip the freshness flush; it must not block the update.
|
||||||
|
oldBaseURL, oldKnown := "", false
|
||||||
|
if existing, err := h.db.GetRemote(r.Context(), name); err == nil {
|
||||||
|
oldBaseURL, oldKnown = existing.BaseURL, true
|
||||||
|
}
|
||||||
if err := h.db.UpdateRemote(r.Context(), &remote); err != nil {
|
if err := h.db.UpdateRemote(r.Context(), &remote); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -152,13 +147,13 @@ func (h *RemotesHandler) update(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Release / APKINDEX): purge it so the next request re-fetches from the new
|
// Release / APKINDEX): purge it so the next request re-fetches from the new
|
||||||
// upstream instead of serving stale data until TTL expiry. A flush failure
|
// upstream instead of serving stale data until TTL expiry. A flush failure
|
||||||
// is logged but does not fail the request — the DB update already landed.
|
// is logged but does not fail the request — the DB update already landed.
|
||||||
if oldKnown && existing.BaseURL != remote.BaseURL && h.cache != nil {
|
if oldKnown && oldBaseURL != remote.BaseURL && h.cache != nil {
|
||||||
if err := h.cache.FlushRemote(r.Context(), name); err != nil {
|
if err := h.cache.FlushRemote(r.Context(), name); err != nil {
|
||||||
slog.Warn("flush cached metadata after base_url change failed",
|
slog.Warn("flush cached metadata after base_url change failed",
|
||||||
"remote", name, "error", err)
|
"remote", name, "error", err)
|
||||||
} else {
|
} else {
|
||||||
slog.Info("flushed cached metadata after base_url change",
|
slog.Info("flushed cached metadata after base_url change",
|
||||||
"remote", name, "old_base_url", existing.BaseURL, "new_base_url", remote.BaseURL)
|
"remote", name, "old_base_url", oldBaseURL, "new_base_url", remote.BaseURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, remote)
|
writeJSON(w, http.StatusOK, remote)
|
||||||
@@ -170,11 +165,6 @@ func (h *RemotesHandler) del(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if h.cache != nil {
|
|
||||||
if err := h.cache.FlushRemote(r.Context(), name); err != nil {
|
|
||||||
slog.Warn("flush cached metadata after delete failed", "remote", name, "error", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -94,28 +94,3 @@ func TestUpdateFlushFailureStillSucceeds(t *testing.T) {
|
|||||||
t.Fatalf("flush calls = %v, want exactly one attempted flush", ff.calls)
|
t.Fatalf("flush calls = %v, want exactly one attempted flush", ff.calls)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deleting a remote flushes its cached state so a re-created remote of the
|
|
||||||
// same name starts cold; a flush error does not fail the delete.
|
|
||||||
func TestDeleteFlushesCache(t *testing.T) {
|
|
||||||
if testDSN == "" {
|
|
||||||
t.Skip("Docker unavailable")
|
|
||||||
}
|
|
||||||
db, err := database.New(testDSN)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer db.Close()
|
|
||||||
|
|
||||||
for _, ff := range []*fakeFlusher{{}, {err: errors.New("redis down")}} {
|
|
||||||
const name = "rpm-flush-delete"
|
|
||||||
seedRemote(t, db, name, "https://example.com/repo")
|
|
||||||
h := NewRemotesHandler(db, ff, nil).Routes()
|
|
||||||
if c := do(t, h, "DELETE", "/"+name, ""); c != 204 {
|
|
||||||
t.Fatalf("delete = %d, want 204", c)
|
|
||||||
}
|
|
||||||
if len(ff.calls) != 1 || ff.calls[0] != name {
|
|
||||||
t.Fatalf("flush calls = %v, want exactly one flush of %q", ff.calls, name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
package v2
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.unkin.net/unkin/artifactapi/internal/database"
|
|
||||||
)
|
|
||||||
|
|
||||||
// An omitted mutable_ttl defaults to 3600 on create and keeps the stored value
|
|
||||||
// on update; an explicit 0 is stored as 0 in both.
|
|
||||||
func TestMutableTTLOmittedVsZero(t *testing.T) {
|
|
||||||
if testDSN == "" {
|
|
||||||
t.Skip("Docker unavailable")
|
|
||||||
}
|
|
||||||
db, err := database.New(testDSN)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer db.Close()
|
|
||||||
h := NewRemotesHandler(db, nil, nil).Routes()
|
|
||||||
ttl := func(name string) int {
|
|
||||||
t.Helper()
|
|
||||||
r, err := db.GetRemote(context.Background(), name)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("get %s: %v", name, err)
|
|
||||||
}
|
|
||||||
return r.MutableTTL
|
|
||||||
}
|
|
||||||
const body = `"package_type":"rpm","base_url":"https://example.com/repo"`
|
|
||||||
|
|
||||||
steps := []struct {
|
|
||||||
method, path, json string
|
|
||||||
want int
|
|
||||||
}{
|
|
||||||
{"POST", "/", `{"name":"ttl-omit",` + body + `}`, 3600},
|
|
||||||
{"PUT", "/ttl-omit", `{` + body + `,"mutable_ttl":120}`, 120},
|
|
||||||
{"PUT", "/ttl-omit", `{` + body + `}`, 120},
|
|
||||||
{"PUT", "/ttl-omit", `{` + body + `,"mutable_ttl":0}`, 0},
|
|
||||||
{"PUT", "/ttl-omit", `{` + body + `}`, 0},
|
|
||||||
}
|
|
||||||
for i, s := range steps {
|
|
||||||
if c := do(t, h, s.method, s.path, s.json); c/100 != 2 {
|
|
||||||
t.Fatalf("step %d %s %s = %d", i, s.method, s.path, c)
|
|
||||||
}
|
|
||||||
if got := ttl("ttl-omit"); got != s.want {
|
|
||||||
t.Errorf("step %d: mutable_ttl = %d, want %d", i, got, s.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if c := do(t, h, "POST", "/", `{"name":"ttl-zero",`+body+`,"mutable_ttl":0}`); c != 201 {
|
|
||||||
t.Fatalf("create explicit 0 = %d", c)
|
|
||||||
}
|
|
||||||
if got := ttl("ttl-zero"); got != 0 {
|
|
||||||
t.Errorf("explicit 0 stored as %d", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Vendored
+7
-49
@@ -61,54 +61,18 @@ func TestTTL(t *testing.T) {
|
|||||||
func TestLock(t *testing.T) {
|
func TestLock(t *testing.T) {
|
||||||
requireRedis(t)
|
requireRedis(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
token, err := testRedis.AcquireLock(ctx, "r", "lockpath", time.Minute)
|
ok, err := testRedis.AcquireLock(ctx, "r", "lockpath", time.Minute)
|
||||||
if err != nil || token == "" {
|
if err != nil || !ok {
|
||||||
t.Fatalf("first acquire should succeed: %q %v", token, err)
|
t.Fatalf("first acquire should succeed: %v %v", ok, err)
|
||||||
}
|
}
|
||||||
if again, _ := testRedis.AcquireLock(ctx, "r", "lockpath", time.Minute); again != "" {
|
if ok, _ := testRedis.AcquireLock(ctx, "r", "lockpath", time.Minute); ok {
|
||||||
t.Error("second acquire should fail while held")
|
t.Error("second acquire should fail while held")
|
||||||
}
|
}
|
||||||
if err := testRedis.ReleaseLock(ctx, "r", "lockpath", "not-the-owner"); err != nil {
|
if err := testRedis.ReleaseLock(ctx, "r", "lockpath"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if again, _ := testRedis.AcquireLock(ctx, "r", "lockpath", time.Minute); again != "" {
|
if ok, _ := testRedis.AcquireLock(ctx, "r", "lockpath", time.Minute); !ok {
|
||||||
t.Error("release with a foreign token must not drop the lock")
|
t.Error("acquire should succeed after release")
|
||||||
}
|
|
||||||
if err := testRedis.ReleaseLock(ctx, "r", "lockpath", token); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
next, _ := testRedis.AcquireLock(ctx, "r", "lockpath", time.Minute)
|
|
||||||
if next == "" {
|
|
||||||
t.Fatal("acquire should succeed after release")
|
|
||||||
}
|
|
||||||
if next == token {
|
|
||||||
t.Error("tokens must differ between holders")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStaleReleaseKeepsNextHolder(t *testing.T) {
|
|
||||||
requireRedis(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
stale, err := testRedis.AcquireLock(ctx, "r", "stale", 50*time.Millisecond)
|
|
||||||
if err != nil || stale == "" {
|
|
||||||
t.Fatalf("acquire: %q %v", stale, err)
|
|
||||||
}
|
|
||||||
time.Sleep(100 * time.Millisecond)
|
|
||||||
fresh, err := testRedis.AcquireLock(ctx, "r", "stale", time.Minute)
|
|
||||||
if err != nil || fresh == "" {
|
|
||||||
t.Fatalf("acquire after expiry: %q %v", fresh, err)
|
|
||||||
}
|
|
||||||
if err := testRedis.ReleaseLock(ctx, "r", "stale", stale); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if again, _ := testRedis.AcquireLock(ctx, "r", "stale", time.Minute); again != "" {
|
|
||||||
t.Fatal("stale holder's release dropped the second holder's lock")
|
|
||||||
}
|
|
||||||
if err := testRedis.ReleaseLock(ctx, "r", "stale", fresh); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if again, _ := testRedis.AcquireLock(ctx, "r", "stale", time.Minute); again == "" {
|
|
||||||
t.Error("owner release should free the lock")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,12 +86,6 @@ func TestETagAndToken(t *testing.T) {
|
|||||||
if v, _ := testRedis.GetETag(ctx, "r", "p"); v != `"abc"` {
|
if v, _ := testRedis.GetETag(ctx, "r", "p"); v != `"abc"` {
|
||||||
t.Errorf("etag = %q", v)
|
t.Errorf("etag = %q", v)
|
||||||
}
|
}
|
||||||
if err := testRedis.SetETag(ctx, "r", "p", "", 0); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if v, _ := testRedis.GetETag(ctx, "r", "p"); v != "" {
|
|
||||||
t.Errorf("empty etag should drop the stored one, got %q", v)
|
|
||||||
}
|
|
||||||
|
|
||||||
if v, _ := testRedis.GetToken(ctx, "missing"); v != "" {
|
if v, _ := testRedis.GetToken(ctx, "missing"); v != "" {
|
||||||
t.Error("missing token should be empty")
|
t.Error("missing token should be empty")
|
||||||
|
|||||||
Vendored
+5
-18
@@ -2,7 +2,6 @@ package cache
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -47,31 +46,19 @@ func (r *Redis) CheckTTL(ctx context.Context, remote, path string) (bool, error)
|
|||||||
return exists > 0, nil
|
return exists > 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AcquireLock returns the owner token on success and "" when the lock is held.
|
func (r *Redis) AcquireLock(ctx context.Context, remote, path string, ttl time.Duration) (bool, error) {
|
||||||
func (r *Redis) AcquireLock(ctx context.Context, remote, path string, ttl time.Duration) (string, error) {
|
|
||||||
key := fmt.Sprintf("lock:%s:%s", remote, path)
|
key := fmt.Sprintf("lock:%s:%s", remote, path)
|
||||||
token := rand.Text()
|
ok, err := r.client.SetNX(ctx, key, "1", ttl).Result()
|
||||||
ok, err := r.client.SetNX(ctx, key, token, ttl).Result()
|
return ok, err
|
||||||
if err != nil || !ok {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return token, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var releaseScript = redis.NewScript(`if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end`)
|
func (r *Redis) ReleaseLock(ctx context.Context, remote, path string) error {
|
||||||
|
|
||||||
// ReleaseLock deletes the lock only while token still owns it.
|
|
||||||
func (r *Redis) ReleaseLock(ctx context.Context, remote, path, token string) error {
|
|
||||||
key := fmt.Sprintf("lock:%s:%s", remote, path)
|
key := fmt.Sprintf("lock:%s:%s", remote, path)
|
||||||
return releaseScript.Run(ctx, r.client, []string{key}, token).Err()
|
return r.client.Del(ctx, key).Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetETag stores etag for path, or drops the stored one when etag is empty.
|
|
||||||
func (r *Redis) SetETag(ctx context.Context, remote, path, etag string, ttl time.Duration) error {
|
func (r *Redis) SetETag(ctx context.Context, remote, path, etag string, ttl time.Duration) error {
|
||||||
key := fmt.Sprintf("etag:%s:%s", remote, path)
|
key := fmt.Sprintf("etag:%s:%s", remote, path)
|
||||||
if etag == "" {
|
|
||||||
return r.client.Del(ctx, key).Err()
|
|
||||||
}
|
|
||||||
return r.client.Set(ctx, key, etag, ttl).Err()
|
return r.client.Set(ctx, key, etag, ttl).Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -207,72 +207,3 @@ func TestGitHubSyncLeaseFailureBackoff(t *testing.T) {
|
|||||||
t.Fatal("claimed inside freshness window after a successful sync")
|
t.Fatal("claimed inside freshness window after a successful sync")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deleting a remote drops its sync state, so a recreated remote of the same name
|
|
||||||
// starts clean instead of inheriting the old ETag, sync time and retry state.
|
|
||||||
func TestGitHubSyncStateDeletedWithRemote(t *testing.T) {
|
|
||||||
requireDB(t)
|
|
||||||
for _, tc := range []struct {
|
|
||||||
table string
|
|
||||||
pt models.PackageType
|
|
||||||
claim func(name string) (bool, string, error)
|
|
||||||
release func(name string, res provider.SyncResult) error
|
|
||||||
}{
|
|
||||||
{"github_rpm_sync_state", models.PackageGitHubRPM,
|
|
||||||
func(n string) (bool, string, error) {
|
|
||||||
return testDB.ClaimGitHubSyncLease(ctx(), n, "r1", time.Hour, time.Minute)
|
|
||||||
},
|
|
||||||
func(n string, res provider.SyncResult) error {
|
|
||||||
return testDB.ReleaseGitHubSyncLease(ctx(), n, "r1", res)
|
|
||||||
}},
|
|
||||||
{"github_deb_sync_state", models.PackageGitHubDeb,
|
|
||||||
func(n string) (bool, string, error) {
|
|
||||||
return testDB.ClaimGitHubDebSyncLease(ctx(), n, "r1", time.Hour, time.Minute)
|
|
||||||
},
|
|
||||||
func(n string, res provider.SyncResult) error {
|
|
||||||
return testDB.ReleaseGitHubDebSyncLease(ctx(), n, "r1", res)
|
|
||||||
}},
|
|
||||||
{"github_alpine_sync_state", models.PackageGitHubAlpine,
|
|
||||||
func(n string) (bool, string, error) {
|
|
||||||
return testDB.ClaimGitHubAlpineSyncLease(ctx(), n, "r1", time.Hour, time.Minute)
|
|
||||||
},
|
|
||||||
func(n string, res provider.SyncResult) error {
|
|
||||||
return testDB.ReleaseGitHubAlpineSyncLease(ctx(), n, "r1", res)
|
|
||||||
}},
|
|
||||||
} {
|
|
||||||
t.Run(tc.table, func(t *testing.T) {
|
|
||||||
name := "gh-recreate-" + time.Now().Format("150405.000000")
|
|
||||||
create := func() {
|
|
||||||
t.Helper()
|
|
||||||
if err := testDB.CreateRemote(ctx(), &models.Remote{
|
|
||||||
Name: name, PackageType: tc.pt, RepoType: models.RepoTypeRemote,
|
|
||||||
BaseURL: "https://api.github.com/repos/acme/tools", ReleasesRemote: "github", MutableTTL: 3600,
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("create remote: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
create()
|
|
||||||
if ok, _, err := tc.claim(name); err != nil || !ok {
|
|
||||||
t.Fatalf("claim: ok=%v err=%v", ok, err)
|
|
||||||
}
|
|
||||||
if err := tc.release(name, provider.SyncResult{Etag: `"old"`}); err != nil {
|
|
||||||
t.Fatalf("release: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := testDB.DeleteRemote(ctx(), name); err != nil {
|
|
||||||
t.Fatalf("delete remote: %v", err)
|
|
||||||
}
|
|
||||||
var rows int
|
|
||||||
if err := testDB.Pool.QueryRow(ctx(), `SELECT count(*) FROM `+tc.table+` WHERE remote_name = $1`, name).Scan(&rows); err != nil || rows != 0 {
|
|
||||||
t.Fatalf("sync state left after delete: rows=%d err=%v", rows, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
create()
|
|
||||||
ok, etag, err := tc.claim(name)
|
|
||||||
if err != nil || !ok || etag != "" {
|
|
||||||
t.Fatalf("recreated remote claim ok=%v etag=%q err=%v, want a fresh claim with no etag", ok, etag, err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
package database
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"git.unkin.net/unkin/artifactapi/internal/provider"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (db *DB) InsertHelmMetadata(ctx context.Context, meta *provider.HelmMetadata) error {
|
|
||||||
_, err := db.Pool.Exec(ctx, `
|
|
||||||
INSERT INTO helm_metadata (repo_name, file_path, content_hash, name, version, chart_yaml)
|
|
||||||
VALUES ($1,$2,$3,$4,$5,$6)
|
|
||||||
ON CONFLICT (repo_name, file_path) DO NOTHING
|
|
||||||
`, meta.RepoName, meta.FilePath, meta.ContentHash, meta.Name, meta.Version, meta.ChartYAML)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (db *DB) DeleteHelmMetadata(ctx context.Context, repoName, filePath string) error {
|
|
||||||
_, err := db.Pool.Exec(ctx, `DELETE FROM helm_metadata WHERE repo_name = $1 AND file_path = $2`, repoName, filePath)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (db *DB) ListHelmMetadataEntries(ctx context.Context, repoName string) ([]provider.HelmMetadata, error) {
|
|
||||||
rows, err := db.Pool.Query(ctx, `
|
|
||||||
SELECT repo_name, file_path, content_hash, name, version, chart_yaml, created_at
|
|
||||||
FROM helm_metadata
|
|
||||||
WHERE repo_name = $1
|
|
||||||
ORDER BY name, version, file_path
|
|
||||||
`, repoName)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var result []provider.HelmMetadata
|
|
||||||
for rows.Next() {
|
|
||||||
var m provider.HelmMetadata
|
|
||||||
if err := rows.Scan(&m.RepoName, &m.FilePath, &m.ContentHash, &m.Name, &m.Version, &m.ChartYAML, &m.CreatedAt); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
result = append(result, m)
|
|
||||||
}
|
|
||||||
return result, rows.Err()
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
package database
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"git.unkin.net/unkin/artifactapi/internal/provider"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (db *DB) InsertPuppetMetadata(ctx context.Context, meta *provider.PuppetMetadata) error {
|
|
||||||
_, err := db.Pool.Exec(ctx, `
|
|
||||||
INSERT INTO puppet_metadata (repo_name, file_path, content_hash, slug, version, metadata_json, file_size, file_md5)
|
|
||||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
|
|
||||||
ON CONFLICT (repo_name, file_path) DO NOTHING
|
|
||||||
`, meta.RepoName, meta.FilePath, meta.ContentHash, meta.Slug, meta.Version, meta.MetadataJSON, meta.Size, meta.MD5)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (db *DB) DeletePuppetMetadata(ctx context.Context, repoName, filePath string) error {
|
|
||||||
_, err := db.Pool.Exec(ctx, `DELETE FROM puppet_metadata WHERE repo_name = $1 AND file_path = $2`, repoName, filePath)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListPuppetMetadataEntries returns a repo's releases, optionally narrowed to
|
|
||||||
// one module slug (empty slug lists every module).
|
|
||||||
func (db *DB) ListPuppetMetadataEntries(ctx context.Context, repoName, slug string) ([]provider.PuppetMetadata, error) {
|
|
||||||
rows, err := db.Pool.Query(ctx, `
|
|
||||||
SELECT repo_name, file_path, content_hash, slug, version, metadata_json, file_size, file_md5, created_at
|
|
||||||
FROM puppet_metadata
|
|
||||||
WHERE repo_name = $1 AND ($2 = '' OR slug = $2)
|
|
||||||
ORDER BY slug, file_path
|
|
||||||
`, repoName, slug)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var result []provider.PuppetMetadata
|
|
||||||
for rows.Next() {
|
|
||||||
var m provider.PuppetMetadata
|
|
||||||
if err := rows.Scan(&m.RepoName, &m.FilePath, &m.ContentHash, &m.Slug, &m.Version, &m.MetadataJSON, &m.Size, &m.MD5, &m.CreatedAt); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
result = append(result, m)
|
|
||||||
}
|
|
||||||
return result, rows.Err()
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
package all
|
|
||||||
|
|
||||||
import _ "git.unkin.net/unkin/artifactapi/internal/provider/alpine"
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
package all
|
|
||||||
|
|
||||||
import _ "git.unkin.net/unkin/artifactapi/internal/provider/cargo"
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
package all
|
|
||||||
|
|
||||||
import _ "git.unkin.net/unkin/artifactapi/internal/provider/deb"
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
package all
|
|
||||||
|
|
||||||
import _ "git.unkin.net/unkin/artifactapi/internal/provider/docker"
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
package all
|
|
||||||
|
|
||||||
import _ "git.unkin.net/unkin/artifactapi/internal/provider/gems"
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
package all
|
|
||||||
|
|
||||||
import _ "git.unkin.net/unkin/artifactapi/internal/provider/generic"
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
package all
|
|
||||||
|
|
||||||
import _ "git.unkin.net/unkin/artifactapi/internal/provider/goproxy"
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
package all
|
|
||||||
|
|
||||||
import _ "git.unkin.net/unkin/artifactapi/internal/provider/helm"
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
package all
|
|
||||||
|
|
||||||
import _ "git.unkin.net/unkin/artifactapi/internal/provider/npm"
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
package all
|
|
||||||
|
|
||||||
import _ "git.unkin.net/unkin/artifactapi/internal/provider/nuget"
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
package all
|
|
||||||
|
|
||||||
import _ "git.unkin.net/unkin/artifactapi/internal/provider/puppet"
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
package all
|
|
||||||
|
|
||||||
import _ "git.unkin.net/unkin/artifactapi/internal/provider/pypi"
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
package all
|
|
||||||
|
|
||||||
import _ "git.unkin.net/unkin/artifactapi/internal/provider/rpm"
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
package all
|
|
||||||
|
|
||||||
import _ "git.unkin.net/unkin/artifactapi/internal/provider/terraform"
|
|
||||||
@@ -372,9 +372,6 @@ func (p *GitHubProvider) scanWithState(ctx context.Context, remote models.Remote
|
|||||||
return newEtag, false, err
|
return newEtag, false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// A transiently failed asset fails the scan so the ETag is not advanced and
|
|
||||||
// the retry re-derives it; an invalid package is skipped as no retry fixes it.
|
|
||||||
var failed []error
|
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
for _, rel := range releases {
|
for _, rel := range releases {
|
||||||
if rel.Draft {
|
if rel.Draft {
|
||||||
@@ -403,14 +400,10 @@ func (p *GitHubProvider) scanWithState(ctx context.Context, remote models.Remote
|
|||||||
meta, err := p.deriveAsset(ctx, remote, asset, fp)
|
meta, err := p.deriveAsset(ctx, remote, asset, fp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("github_alpine: derive asset failed", "remote", remote.Name, "asset", asset.Name, "error", err)
|
slog.Warn("github_alpine: derive asset failed", "remote", remote.Name, "asset", asset.Name, "error", err)
|
||||||
if !errors.Is(err, provider.ErrInvalidPackage) {
|
|
||||||
failed = append(failed, fmt.Errorf("%s: %w", asset.Name, err))
|
|
||||||
}
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := inserter.InsertAlpineMetadata(ctx, meta); err != nil {
|
if err := inserter.InsertAlpineMetadata(ctx, meta); err != nil {
|
||||||
slog.Error("github_alpine: insert metadata failed", "remote", remote.Name, "asset", asset.Name, "error", err)
|
slog.Error("github_alpine: insert metadata failed", "remote", remote.Name, "asset", asset.Name, "error", err)
|
||||||
failed = append(failed, fmt.Errorf("%s: %w", asset.Name, err))
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
slog.Info("github_alpine: derived asset", "remote", remote.Name, "name", meta.Name, "version", meta.Version, "arch", meta.Arch)
|
slog.Info("github_alpine: derived asset", "remote", remote.Name, "name", meta.Name, "version", meta.Version, "arch", meta.Arch)
|
||||||
@@ -422,7 +415,7 @@ func (p *GitHubProvider) scanWithState(ctx context.Context, remote models.Remote
|
|||||||
_ = deleter.DeleteAlpineMetadata(ctx, remote.Name, fp)
|
_ = deleter.DeleteAlpineMetadata(ctx, remote.Name, fp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return newEtag, true, errors.Join(failed...)
|
return newEtag, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type ghRelease struct {
|
type ghRelease struct {
|
||||||
@@ -503,7 +496,7 @@ func (p *GitHubProvider) deriveAsset(ctx context.Context, remote models.Remote,
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if meta.Name == "" || meta.Arch == "" {
|
if meta.Name == "" || meta.Arch == "" {
|
||||||
return nil, fmt.Errorf("%w: .PKGINFO missing pkgname/arch", provider.ErrInvalidPackage)
|
return nil, errors.New(".PKGINFO missing pkgname/arch")
|
||||||
}
|
}
|
||||||
|
|
||||||
meta.RepoName = remote.Name
|
meta.RepoName = remote.Name
|
||||||
@@ -533,13 +526,13 @@ func (p *GitHubProvider) fetchPkginfo(ctx context.Context, remote models.Remote,
|
|||||||
}
|
}
|
||||||
meta, complete, perr := pkginfoFromPrefix(body)
|
meta, complete, perr := pkginfoFromPrefix(body)
|
||||||
if perr != nil {
|
if perr != nil {
|
||||||
return nil, fmt.Errorf("%w: parse apk .PKGINFO: %w", provider.ErrInvalidPackage, perr)
|
return nil, fmt.Errorf("parse apk .PKGINFO: %w", perr)
|
||||||
}
|
}
|
||||||
if complete {
|
if complete {
|
||||||
return meta, nil
|
return meta, nil
|
||||||
}
|
}
|
||||||
if full || n >= p.headerMax {
|
if full || n >= p.headerMax {
|
||||||
return nil, fmt.Errorf("%w: .PKGINFO not found within %d bytes of %s", provider.ErrInvalidPackage, n, downloadURL)
|
return nil, fmt.Errorf(".PKGINFO not found within %d bytes of %s", n, downloadURL)
|
||||||
}
|
}
|
||||||
n *= 2
|
n *= 2
|
||||||
if n > p.headerMax {
|
if n > p.headerMax {
|
||||||
@@ -601,7 +594,7 @@ func pkginfoFromPrefix(prefix []byte) (meta *provider.AlpineMetadata, complete b
|
|||||||
}
|
}
|
||||||
|
|
||||||
// rangeGet returns the first n bytes of downloadURL. full is true when the
|
// rangeGet returns the first n bytes of downloadURL. full is true when the
|
||||||
// response holds the whole object.
|
// response body was shorter than n (i.e. we already have the whole object).
|
||||||
func (p *GitHubProvider) rangeGet(ctx context.Context, remote models.Remote, downloadURL string, n int64) ([]byte, bool, error) {
|
func (p *GitHubProvider) rangeGet(ctx context.Context, remote models.Remote, downloadURL string, n int64) ([]byte, bool, error) {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -623,9 +616,15 @@ func (p *GitHubProvider) rangeGet(ctx context.Context, remote models.Remote, dow
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {
|
||||||
return nil, false, fmt.Errorf("range GET: %w", provider.AssetStatusError(downloadURL, resp))
|
return nil, false, fmt.Errorf("range GET %s: status %d", downloadURL, resp.StatusCode)
|
||||||
}
|
}
|
||||||
return provider.ReadPrefix(resp, n)
|
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, n))
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
full := int64(len(body)) < n
|
||||||
|
return body, full, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// assetPath is the package's location relative to github.com — the path the
|
// assetPath is the package's location relative to github.com — the path the
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -89,17 +88,15 @@ type githubFixture struct {
|
|||||||
notModHit int
|
notModHit int
|
||||||
releaseAuth string
|
releaseAuth string
|
||||||
assetAuth string
|
assetAuth string
|
||||||
assetFail map[string]int // asset filename -> downloads left to answer 500
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func newGitHubFixture(t *testing.T, withDigest bool) *githubFixture {
|
func newGitHubFixture(t *testing.T, withDigest bool) *githubFixture {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
f := &githubFixture{
|
f := &githubFixture{
|
||||||
apkBytes: map[string][]byte{},
|
apkBytes: map[string][]byte{},
|
||||||
rangeHit: map[string]int{},
|
rangeHit: map[string]int{},
|
||||||
fullHit: map[string]int{},
|
fullHit: map[string]int{},
|
||||||
assetFail: map[string]int{},
|
|
||||||
}
|
}
|
||||||
f.apkBytes["demo-1.2.3-r0.apk"] = testsupport.MinimalApk("demo", "1.2.3-r0", "x86_64")
|
f.apkBytes["demo-1.2.3-r0.apk"] = testsupport.MinimalApk("demo", "1.2.3-r0", "x86_64")
|
||||||
|
|
||||||
@@ -149,12 +146,6 @@ func newGitHubFixture(t *testing.T, withDigest bool) *githubFixture {
|
|||||||
}
|
}
|
||||||
rng := r.Header.Get("Range")
|
rng := r.Header.Get("Range")
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
if f.assetFail[name] > 0 {
|
|
||||||
f.assetFail[name]--
|
|
||||||
f.mu.Unlock()
|
|
||||||
http.Error(w, "boom", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
f.assetAuth = r.Header.Get("Authorization")
|
f.assetAuth = r.Header.Get("Authorization")
|
||||||
if rng != "" {
|
if rng != "" {
|
||||||
f.rangeHit[name]++
|
f.rangeHit[name]++
|
||||||
@@ -504,13 +495,3 @@ func readAPKIndex(t *testing.T, gzBytes []byte) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGitHubDeriveAssetMissingPkgnameIsInvalid(t *testing.T) {
|
|
||||||
fx := newGitHubFixture(t, true)
|
|
||||||
fx.apkBytes["nameless-1-r0.apk"] = testsupport.MinimalApk("", "1-r0", "x86_64")
|
|
||||||
asset := ghAsset{BrowserDownloadURL: fx.srv.URL + "/acme/tools/releases/download/v1.2.3/nameless-1-r0.apk"}
|
|
||||||
_, err := newTestProvider().deriveAsset(context.Background(), fx.remote(), asset, "fp")
|
|
||||||
if !errors.Is(err, provider.ErrInvalidPackage) {
|
|
||||||
t.Fatalf("want ErrInvalidPackage, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -308,58 +308,3 @@ func TestSyncerPrimeBypassesRecencyPeriodicDoesNot(t *testing.T) {
|
|||||||
t.Fatalf("periodic scan ran inside recency window: %d -> %d releases calls", releasesAfterPrime, fx.releasesHit)
|
t.Fatalf("periodic scan ran inside recency window: %d -> %d releases calls", releasesAfterPrime, fx.releasesHit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A transiently failed asset fails the scan: the ETag is not advanced, a retry
|
|
||||||
// is scheduled, and the retry against the unchanged release list derives it. An
|
|
||||||
// invalid package is skipped instead, since no retry can fix it.
|
|
||||||
func TestSyncerFailedAssetRetry(t *testing.T) {
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name string
|
|
||||||
corrupt bool
|
|
||||||
wantRetry bool
|
|
||||||
}{
|
|
||||||
{name: "transient", wantRetry: true},
|
|
||||||
{name: "invalid package", corrupt: true},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
fx := newGitHubFixture(t, true)
|
|
||||||
fx.etag = `"v1"`
|
|
||||||
if tc.corrupt {
|
|
||||||
fx.apkBytes["other-9-r0.apk"] = []byte("not a package")
|
|
||||||
} else {
|
|
||||||
fx.apkBytes["other-9-r0.apk"] = testsupport.MinimalApk("other", "9-r0", "aarch64")
|
|
||||||
fx.assetFail["other-9-r0.apk"] = 1
|
|
||||||
}
|
|
||||||
store := newFakeSyncStore()
|
|
||||||
s := newSyncer(store, newTestProvider(), testSyncConfig())
|
|
||||||
remote := fx.remote()
|
|
||||||
bg := context.Background()
|
|
||||||
|
|
||||||
s.process(bg, syncJob{remote: remote, prime: true})
|
|
||||||
_, pending := store.retryAt[remote.Name]
|
|
||||||
if pending != tc.wantRetry {
|
|
||||||
t.Fatalf("retry pending=%v, want %v", pending, tc.wantRetry)
|
|
||||||
}
|
|
||||||
if _, saved := store.etags[remote.Name]; saved == tc.wantRetry {
|
|
||||||
t.Fatalf("etag saved=%v after first scan", saved)
|
|
||||||
}
|
|
||||||
if rows, _ := store.ListAlpineMetadataEntries(bg, remote.Name); len(rows) != 1 {
|
|
||||||
t.Fatalf("healthy asset not served: %d rows", len(rows))
|
|
||||||
}
|
|
||||||
if !tc.wantRetry {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
store.mu.Lock()
|
|
||||||
store.retryAt[remote.Name] = time.Now().Add(-time.Second)
|
|
||||||
store.mu.Unlock()
|
|
||||||
s.process(bg, syncJob{remote: remote})
|
|
||||||
if _, pending := store.retryAt[remote.Name]; pending || store.etags[remote.Name] != `"v1"` {
|
|
||||||
t.Fatalf("retry not recorded: pending=%v etag=%q", pending, store.etags[remote.Name])
|
|
||||||
}
|
|
||||||
if rows, _ := store.ListAlpineMetadataEntries(bg, remote.Name); len(rows) != 2 {
|
|
||||||
t.Fatalf("retry did not derive the failed asset: %d rows", len(rows))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
package provider
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"io"
|
|
||||||
)
|
|
||||||
|
|
||||||
type FileStore interface {
|
|
||||||
ListFilesByPrefix(ctx context.Context, repoName, prefix string) ([]FileEntry, error)
|
|
||||||
ListPackages(ctx context.Context, repoName string) ([]string, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type BlobReader interface {
|
|
||||||
Download(ctx context.Context, key string) (io.ReadCloser, int64, error)
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
package provider
|
|
||||||
|
|
||||||
type IndexMerger interface {
|
|
||||||
MergeIndexes(members []MemberIndex, proxyBaseURL string) ([]byte, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type MemberIndex struct {
|
|
||||||
RemoteName string
|
|
||||||
Body []byte
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
package provider
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"net/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
type LocalIndexer interface {
|
|
||||||
ServeLocalIndex(w http.ResponseWriter, r *http.Request, files FileStore, repoName, path string) bool
|
|
||||||
GenerateLocalIndex(ctx context.Context, files FileStore, repoName, path string) ([]byte, error)
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
package provider
|
|
||||||
|
|
||||||
type LocalUploader interface {
|
|
||||||
ValidateUpload(filePath string) (storagePath, contentType string, err error)
|
|
||||||
UploadResponse(storagePath, contentHash string, sizeBytes int64) map[string]any
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
package provider
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
)
|
|
||||||
|
|
||||||
// PostDeleteHook lets a provider clean up derived state (e.g. RPM metadata that
|
|
||||||
// feeds generated repodata) after a local file is removed.
|
|
||||||
type PostDeleteHook interface {
|
|
||||||
AfterDelete(ctx context.Context, repoName, storagePath string, db MetadataDeleter) error
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
package provider
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
)
|
|
||||||
|
|
||||||
type PostUploadHook interface {
|
|
||||||
AfterUpload(ctx context.Context, repoName, storagePath, contentHash string, blobs BlobReader, db MetadataStore)
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
package provider
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"git.unkin.net/unkin/artifactapi/pkg/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
// RemoteServer lets a remote provider fully answer a request itself instead of
|
|
||||||
// going through the byte-proxy engine. It is the remote-side analog of
|
|
||||||
// LocalIndexer: a metadata-only remote (e.g. github_rpm) uses it to synthesize
|
|
||||||
// repodata from derived per-asset metadata and to redirect package downloads to
|
|
||||||
// a backend remote, without ever precaching the packages. Returning false lets
|
|
||||||
// the normal proxy path take over.
|
|
||||||
type RemoteServer interface {
|
|
||||||
ServeRemote(w http.ResponseWriter, r *http.Request, remote models.Remote, path, proxyBaseURL string, store RemoteMetadataStore) bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemoteMetadataStore is the persistence surface a RemoteServer needs to cache
|
|
||||||
// and read the metadata it derives per upstream asset. *database.DB satisfies it.
|
|
||||||
type RemoteMetadataStore interface {
|
|
||||||
RPMMetadataReader
|
|
||||||
MetadataStore
|
|
||||||
MetadataDeleter
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
package provider
|
|
||||||
|
|
||||||
// ResponseRewriter marks providers whose RewriteResponse changes mutable
|
|
||||||
// bodies, so the served length is only known after a fetch.
|
|
||||||
type ResponseRewriter interface {
|
|
||||||
RewritesResponse()
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
package provider
|
|
||||||
|
|
||||||
import (
|
|
||||||
"git.unkin.net/unkin/artifactapi/pkg/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ServeRewriter rewrites a cached mutable remote response per request, for
|
|
||||||
// indexes whose URLs must name the requesting host and so cannot be rewritten
|
|
||||||
// before caching.
|
|
||||||
type ServeRewriter interface {
|
|
||||||
RewriteServed(body []byte, remote models.Remote, proxyBaseURL string) []byte
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user