Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c20009599 | |||
| 7d284391ca | |||
| 6e6481e09a | |||
| aaa923ffd1 | |||
| 0150b5ebb4 | |||
| 9b91aa39a6 | |||
| 76d108d510 | |||
| cea1da59f6 | |||
| 1c61162dc7 | |||
| 4747693fa8 | |||
| d8e2446262 |
@@ -672,7 +672,7 @@ func TestMain(m *testing.M) {
|
||||
|
||||
```hcl
|
||||
provider "artifactapi" {
|
||||
endpoint = "https://artifactapi.k8s.syd1.au.unkin.net"
|
||||
endpoint = "https://artifactapi.example.com"
|
||||
}
|
||||
|
||||
resource "artifactapi_remote" "terraform_registry" {
|
||||
|
||||
@@ -20,98 +20,25 @@ API: `http://localhost:8000` | Frontend: `http://localhost:5173`
|
||||
|
||||
## Package Types
|
||||
|
||||
| Type | Mutable (auto-detected) | Immutable (auto-detected) |
|
||||
|---|---|---|
|
||||
| `generic` | nothing | everything |
|
||||
| `docker` | tag manifests, `/tags/list` | blobs, digest manifests |
|
||||
| `helm` | `index.yaml` | `.tgz` charts |
|
||||
| `pypi` | `simple/*` index pages | `.whl`, `.tar.gz` |
|
||||
| `npm` | package metadata | `.tgz` tarballs |
|
||||
| `rpm` | `repomd.xml`, `repodata/*` | `.rpm` |
|
||||
| `alpine` | `APKINDEX.tar.gz` | `.apk` |
|
||||
| `puppet` | `v3/modules/*`, `v3/releases*` | `.tar.gz` |
|
||||
| `terraform` | `*/versions` | `*/download/*/*` |
|
||||
| `goproxy` | `@v/list`, `@latest` | `.info`, `.mod`, `.zip` |
|
||||
| `cargo` | sparse index files (`config.json` synthesized) | `crates/*/*.crate` |
|
||||
| `nuget` | `index.json` (service index), registration and flat container indexes | `.nupkg`, `.nuspec` |
|
||||
| `github_rpm` | `repodata/*` (synthesized) | `.rpm` (redirected) |
|
||||
|
||||
Providers classify paths automatically. Users only configure what to proxy and TTLs.
|
||||
|
||||
### `github_rpm` — GitHub releases as a yum repo (metadata-only, no precache)
|
||||
Classification precedence, TTLs and eviction for every remote: [docs/common/caching.md](docs/common/caching.md).
|
||||
|
||||
A `github_rpm` remote turns a GitHub repo's **releases** into a real `dnf`/`yum`
|
||||
repository without ever caching the packages. It scans releases for `.rpm`
|
||||
assets, derives each package's metadata (NEVRA, requires/provides/conflicts/
|
||||
obsoletes, files, checksum) and **synthesizes `repodata/` on the fly**. Package
|
||||
metadata comes from a **ranged GET of just the RPM header** (the header sits at
|
||||
the front of the file, so the whole package is never downloaded); the sha256
|
||||
checksum comes from the GitHub asset `digest` when present, else a one-time
|
||||
lazy stream. Derived metadata is cached (keyed by asset) so repodata generation
|
||||
is served from primed DB rows, never a cold on-demand derive.
|
||||
Each type's paths, Terraform examples and local/virtual behaviour are in `docs/<type>/`:
|
||||
|
||||
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.
|
||||
- `alpine`: [remote](docs/alpine/remote.md), [remote-github](docs/alpine/remote-github.md)
|
||||
- `cargo`: [remote](docs/cargo/remote.md)
|
||||
- `deb`: [remote](docs/deb/remote.md), [remote-github](docs/deb/remote-github.md)
|
||||
- `docker`: [remote](docs/docker/remote.md), [local](docs/docker/local.md)
|
||||
- `generic`: [remote](docs/generic/remote.md)
|
||||
- `goproxy`: [remote](docs/goproxy/remote.md)
|
||||
- `helm`: [remote](docs/helm/remote.md), [local](docs/helm/local.md), [virtual](docs/helm/virtual.md)
|
||||
- `npm`: [remote](docs/npm/remote.md), [local](docs/npm/local.md), [virtual](docs/npm/virtual.md)
|
||||
- `nuget`: [remote](docs/nuget/remote.md)
|
||||
- `puppet`: [remote](docs/puppet/remote.md), [local](docs/puppet/local.md)
|
||||
- `pypi`: [remote](docs/pypi/remote.md)
|
||||
- `rpm`: [remote](docs/rpm/remote.md), [remote-github](docs/rpm/remote-github.md)
|
||||
- `terraform`: [remote](docs/terraform/remote.md), [local](docs/terraform/local.md)
|
||||
|
||||
### GitHub authentication
|
||||
|
||||
@@ -180,105 +107,9 @@ GITHUB_APP_PRIVATE_KEY_PATH=/etc/artifactapi/github-app.pem
|
||||
|
||||
## Terraform
|
||||
|
||||
Remotes and virtuals are managed by Terraform. Each package type has its own resource:
|
||||
Remotes and virtuals are managed by Terraform. Each package type has its own resource; see `docs/<type>/` for examples.
|
||||
|
||||
```hcl
|
||||
resource "artifactapi_remote_generic" "github" {
|
||||
name = "github"
|
||||
base_url = "https://github.com"
|
||||
|
||||
immutable_ttl = 0
|
||||
mutable_ttl = 7200
|
||||
|
||||
patterns = [
|
||||
"ducaale/xh/.*/xh-.*-x86_64-unknown-linux-musl.tar.gz$",
|
||||
"mikefarah/yq/.*/yq_linux_amd64$",
|
||||
]
|
||||
|
||||
mutable_patterns = [
|
||||
".*/archive/refs/heads/.*\\.tar\\.gz$",
|
||||
]
|
||||
}
|
||||
|
||||
resource "artifactapi_remote_docker" "dockerhub" {
|
||||
name = "dockerhub"
|
||||
base_url = "https://registry-1.docker.io"
|
||||
|
||||
immutable_ttl = 0
|
||||
mutable_ttl = 300
|
||||
ban_tags_enabled = true
|
||||
ban_tags = ["latest"]
|
||||
|
||||
patterns = [
|
||||
"^library/postgres",
|
||||
"^library/redis",
|
||||
]
|
||||
}
|
||||
|
||||
resource "artifactapi_remote_helm" "jetstack" {
|
||||
name = "jetstack"
|
||||
base_url = "https://charts.jetstack.io"
|
||||
|
||||
immutable_ttl = 0
|
||||
mutable_ttl = 3600
|
||||
}
|
||||
|
||||
resource "artifactapi_virtual" "helm" {
|
||||
name = "helm"
|
||||
package_type = "helm"
|
||||
members = [artifactapi_remote_helm.jetstack.name]
|
||||
}
|
||||
```
|
||||
|
||||
Provider: [terraform-provider-artifactapi](../terraform-provider-artifactapi)
|
||||
|
||||
### Serving providers as a registry
|
||||
|
||||
A local `terraform` repo is a real provider registry: upload
|
||||
`terraform-provider-{type}_{version}_{os}_{arch}.zip` files under
|
||||
`{namespace}/{type}/`, and Terraform installs them from a bare source address —
|
||||
no `.terraformrc` mirror config:
|
||||
|
||||
```hcl
|
||||
terraform {
|
||||
required_providers {
|
||||
artifactapi = {
|
||||
source = "artifactapi.k8s.syd1.au.unkin.net/<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.
|
||||
Provider: [terraform-provider-artifactapi](https://git.unkin.net/unkin/terraform-provider-artifactapi)
|
||||
|
||||
## Access Control
|
||||
|
||||
@@ -298,6 +129,7 @@ No patterns + no blocklist = open proxy. Provider handles mutability classificat
|
||||
```
|
||||
GET /api/v1/remote/{name}/{path} Proxy/cache artifact
|
||||
GET /api/v1/virtual/{name}/{path} Virtual repo (merged index)
|
||||
PUT /api/v1/local/{name}/{package} npm publish (local npm repos)
|
||||
GET /v2/{name}/{path} Docker Registry v2
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# `github_alpine` remote
|
||||
|
||||
Shared classification, TTL and eviction rules: [caching](../common/caching.md).
|
||||
|
||||
| Type | Mutable (auto-detected) | Immutable (auto-detected) |
|
||||
|---|---|---|
|
||||
| `github_alpine` | `APKINDEX.tar.gz` (synthesized) | `.apk` (redirected) |
|
||||
@@ -0,0 +1,7 @@
|
||||
# `alpine` remote
|
||||
|
||||
Shared classification, TTL and eviction rules: [caching](../common/caching.md).
|
||||
|
||||
| Type | Mutable (auto-detected) | Immutable (auto-detected) |
|
||||
|---|---|---|
|
||||
| `alpine` | `APKINDEX.tar.gz` | `.apk` |
|
||||
@@ -0,0 +1,7 @@
|
||||
# `cargo` remote
|
||||
|
||||
Shared classification, TTL and eviction rules: [caching](../common/caching.md).
|
||||
|
||||
| Type | Mutable (auto-detected) | Immutable (auto-detected) |
|
||||
|---|---|---|
|
||||
| `cargo` | sparse index files (`config.json` synthesized) | `crates/*/*.crate` |
|
||||
@@ -0,0 +1,100 @@
|
||||
# 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, and the API stores `0` when the field is omitted. |
|
||||
| `check_mutable` | Stores the upstream ETag and tries a conditional `HEAD` (`If-None-Match`) when a mutable path is fetched. The ETag expires with the `mutable_ttl` freshness key, so an expired mutable path is refetched in full, not revalidated. |
|
||||
| `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 | refetched from upstream (the ETag expires with the TTL, so `check_mutable` does not revalidate it) |
|
||||
| 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.
|
||||
- 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), [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).
|
||||
@@ -0,0 +1,7 @@
|
||||
# `github_deb` remote
|
||||
|
||||
Shared classification, TTL and eviction rules: [caching](../common/caching.md).
|
||||
|
||||
| Type | Mutable (auto-detected) | Immutable (auto-detected) |
|
||||
|---|---|---|
|
||||
| `github_deb` | `Packages*`, `Release*` (synthesized) | `.deb` (redirected) |
|
||||
@@ -0,0 +1,7 @@
|
||||
# `deb` remote
|
||||
|
||||
Shared classification, TTL and eviction rules: [caching](../common/caching.md).
|
||||
|
||||
| Type | Mutable (auto-detected) | Immutable (auto-detected) |
|
||||
|---|---|---|
|
||||
| `deb` | `Packages*`, `Sources*`, `Release`, `InRelease`, `Release.gpg`, `Contents-*` | `.deb` |
|
||||
@@ -0,0 +1,17 @@
|
||||
# Local docker registry
|
||||
|
||||
A local `docker` repo is a real container registry, not a mirror: it serves the
|
||||
Docker Registry HTTP API V2 for both push and pull, so any client (`docker`,
|
||||
`podman`, `skopeo`, `buildah`) can use it directly.
|
||||
|
||||
```sh
|
||||
docker tag myapp:latest artifactapi.example.com/docker-internal/myapp:latest
|
||||
docker push artifactapi.example.com/docker-internal/myapp:latest
|
||||
docker pull artifactapi.example.com/docker-internal/myapp:latest
|
||||
```
|
||||
|
||||
The first path segment after `/v2/` is the artifactapi repo name; the remainder
|
||||
is the image name. Blobs and manifests are stored through the shared
|
||||
content-addressable store (deduplicated by digest, reaped by GC once
|
||||
unreferenced); tags are mutable references and re-pushing a tag moves it. Blob
|
||||
uploads support both the monolithic and chunked (`POST`/`PATCH`/`PUT`) flows.
|
||||
@@ -0,0 +1,26 @@
|
||||
# `docker` remote
|
||||
|
||||
Shared classification, TTL and eviction rules: [caching](../common/caching.md).
|
||||
|
||||
| Type | Mutable (auto-detected) | Immutable (auto-detected) |
|
||||
|---|---|---|
|
||||
| `docker` | tag manifests, `/tags/list` | blobs, digest manifests |
|
||||
|
||||
## Terraform
|
||||
|
||||
```hcl
|
||||
resource "artifactapi_remote_docker" "dockerhub" {
|
||||
name = "dockerhub"
|
||||
base_url = "https://registry-1.docker.io"
|
||||
|
||||
immutable_ttl = 0
|
||||
mutable_ttl = 300
|
||||
ban_tags_enabled = true
|
||||
ban_tags = ["latest"]
|
||||
|
||||
patterns = [
|
||||
"^library/postgres",
|
||||
"^library/redis",
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,28 @@
|
||||
# `generic` remote
|
||||
|
||||
Shared classification, TTL and eviction rules: [caching](../common/caching.md).
|
||||
|
||||
| Type | Mutable (auto-detected) | Immutable (auto-detected) |
|
||||
|---|---|---|
|
||||
| `generic` | nothing | everything |
|
||||
|
||||
## Terraform
|
||||
|
||||
```hcl
|
||||
resource "artifactapi_remote_generic" "github" {
|
||||
name = "github"
|
||||
base_url = "https://github.com"
|
||||
|
||||
immutable_ttl = 0
|
||||
mutable_ttl = 7200
|
||||
|
||||
patterns = [
|
||||
"ducaale/xh/.*/xh-.*-x86_64-unknown-linux-musl.tar.gz$",
|
||||
"mikefarah/yq/.*/yq_linux_amd64$",
|
||||
]
|
||||
|
||||
mutable_patterns = [
|
||||
".*/archive/refs/heads/.*\\.tar\\.gz$",
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,7 @@
|
||||
# `goproxy` remote
|
||||
|
||||
Shared classification, TTL and eviction rules: [caching](../common/caching.md).
|
||||
|
||||
| Type | Mutable (auto-detected) | Immutable (auto-detected) |
|
||||
|---|---|---|
|
||||
| `goproxy` | `@v/list`, `@latest` | `.info`, `.mod`, `.zip` |
|
||||
@@ -0,0 +1,15 @@
|
||||
# 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>
|
||||
```
|
||||
@@ -0,0 +1,19 @@
|
||||
# `helm` remote
|
||||
|
||||
Shared classification, TTL and eviction rules: [caching](../common/caching.md).
|
||||
|
||||
| Type | Mutable (auto-detected) | Immutable (auto-detected) |
|
||||
|---|---|---|
|
||||
| `helm` | `index.yaml` | `.tgz` charts |
|
||||
|
||||
## Terraform
|
||||
|
||||
```hcl
|
||||
resource "artifactapi_remote_helm" "jetstack" {
|
||||
name = "jetstack"
|
||||
base_url = "https://charts.jetstack.io"
|
||||
|
||||
immutable_ttl = 0
|
||||
mutable_ttl = 3600
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,9 @@
|
||||
# `helm` virtual
|
||||
|
||||
```hcl
|
||||
resource "artifactapi_virtual" "helm" {
|
||||
name = "helm"
|
||||
package_type = "helm"
|
||||
members = [artifactapi_remote_helm.jetstack.name]
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,14 @@
|
||||
# Local npm registry
|
||||
|
||||
A local `npm` repo is a real npm registry: `npm publish` (scoped and unscoped)
|
||||
and `npm install` work against `/api/v1/local/<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.
|
||||
@@ -0,0 +1,7 @@
|
||||
# `npm` remote
|
||||
|
||||
Shared classification, TTL and eviction rules: [caching](../common/caching.md).
|
||||
|
||||
| Type | Mutable (auto-detected) | Immutable (auto-detected) |
|
||||
|---|---|---|
|
||||
| `npm` | package metadata | `.tgz` tarballs |
|
||||
@@ -0,0 +1,30 @@
|
||||
# `npm` virtual
|
||||
|
||||
An npm virtual serves one registry over local and remote npm members:
|
||||
`npm install` and `npm view` work against `/api/v1/virtual/<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.
|
||||
@@ -0,0 +1,7 @@
|
||||
# `nuget` remote
|
||||
|
||||
Shared classification, TTL and eviction rules: [caching](../common/caching.md).
|
||||
|
||||
| Type | Mutable (auto-detected) | Immutable (auto-detected) |
|
||||
|---|---|---|
|
||||
| `nuget` | `index.json` (service index), registration and flat container indexes | `.nupkg`, `.nuspec` |
|
||||
@@ -0,0 +1,14 @@
|
||||
# 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>
|
||||
```
|
||||
@@ -0,0 +1,7 @@
|
||||
# `puppet` remote
|
||||
|
||||
Shared classification, TTL and eviction rules: [caching](../common/caching.md).
|
||||
|
||||
| Type | Mutable (auto-detected) | Immutable (auto-detected) |
|
||||
|---|---|---|
|
||||
| `puppet` | `v3/modules/*`, `v3/releases*` | `.tar.gz` |
|
||||
@@ -0,0 +1,7 @@
|
||||
# `pypi` remote
|
||||
|
||||
Shared classification, TTL and eviction rules: [caching](../common/caching.md).
|
||||
|
||||
| Type | Mutable (auto-detected) | Immutable (auto-detected) |
|
||||
|---|---|---|
|
||||
| `pypi` | `simple/*` index pages | `.whl`, `.tar.gz` |
|
||||
@@ -0,0 +1,82 @@
|
||||
# `github_rpm` remote
|
||||
|
||||
Shared classification, TTL and eviction rules: [caching](../common/caching.md).
|
||||
|
||||
| Type | Mutable (auto-detected) | Immutable (auto-detected) |
|
||||
|---|---|---|
|
||||
| `github_rpm` | `repodata/*` (synthesized) | `.rpm` (redirected) |
|
||||
|
||||
## GitHub releases as a yum repo (metadata-only, no precache)
|
||||
|
||||
A `github_rpm` remote turns a GitHub repo's **releases** into a real `dnf`/`yum`
|
||||
repository without ever caching the packages. It scans releases for `.rpm`
|
||||
assets, derives each package's metadata (NEVRA, requires/provides/conflicts/
|
||||
obsoletes, files, checksum) and **synthesizes `repodata/` on the fly**. Package
|
||||
metadata comes from a **ranged GET of just the RPM header** (the header sits at
|
||||
the front of the file, so the whole package is never downloaded); the sha256
|
||||
checksum comes from the GitHub asset `digest` when present, else a one-time
|
||||
lazy stream. Derived metadata is cached (keyed by asset) so repodata generation
|
||||
is served from primed DB rows, never a cold on-demand derive.
|
||||
|
||||
Each package's `<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.
|
||||
@@ -0,0 +1,7 @@
|
||||
# `rpm` remote
|
||||
|
||||
Shared classification, TTL and eviction rules: [caching](../common/caching.md).
|
||||
|
||||
| Type | Mutable (auto-detected) | Immutable (auto-detected) |
|
||||
|---|---|---|
|
||||
| `rpm` | `repomd.xml`, `repodata/*` | `.rpm` |
|
||||
@@ -0,0 +1,29 @@
|
||||
# Serving providers as a registry
|
||||
|
||||
A local `terraform` repo is a real provider registry: upload
|
||||
`terraform-provider-{type}_{version}_{os}_{arch}.zip` files under
|
||||
`{namespace}/{type}/`, and Terraform installs them from a bare source address —
|
||||
no `.terraformrc` mirror config:
|
||||
|
||||
```hcl
|
||||
terraform {
|
||||
required_providers {
|
||||
artifactapi = {
|
||||
source = "artifactapi.example.com/<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.
|
||||
@@ -0,0 +1,7 @@
|
||||
# `terraform` remote
|
||||
|
||||
Shared classification, TTL and eviction rules: [caching](../common/caching.md).
|
||||
|
||||
| Type | Mutable (auto-detected) | Immutable (auto-detected) |
|
||||
|---|---|---|
|
||||
| `terraform` | `*/versions` | `*/download/*/*` |
|
||||
@@ -22,14 +22,17 @@ already-running stack.
|
||||
## Coverage
|
||||
|
||||
- **Repository lifecycle** — add / change / delete for remote, local and virtual repos.
|
||||
- **Caching** — one immutable artifact per remote package type (generic, docker,
|
||||
helm, pypi, npm, rpm, alpine, puppet, terraform, goproxy, cargo, nuget) proxied through the
|
||||
mock upstream: first fetch `X-Artifact-Source: remote`, second `cache`, bytes
|
||||
- **Caching** — one immutable artifact per remote package type (one
|
||||
`caching_<type>_test.go` case each) proxied through the mock upstream: first fetch `X-Artifact-Source: remote`, second `cache`, bytes
|
||||
verified against the origin fixture.
|
||||
- **Local uploads** — generic (upload/download), pypi (wheel + generated `simple/`
|
||||
index), rpm (real package + **automatic repodata** generation).
|
||||
- **Local uploads** — puppet (Forge v3 API via real `r10k` and `puppet module install`),
|
||||
generic (upload/download), pypi (wheel + generated `simple/`
|
||||
index), rpm (real package + **automatic repodata** generation), npm (stock
|
||||
`node:22` client: `npm publish` unscoped + scoped, `npm install` + `require()`,
|
||||
second version, republish rejected).
|
||||
- **Virtual repositories** — pypi simple-index merge and helm `index.yaml` merge
|
||||
across two members.
|
||||
across two members; npm packument merge over two locals via a stock `node:22`
|
||||
client (merged versions, install through the member redirect).
|
||||
- **Mirrorlist** — an rpm remote with a `mirrorlist` of extra upstream mirrors
|
||||
(pool = `base_url` + `mirrorlist`): round-robin distribution across both mirrors
|
||||
(constant-body `mockupstreama` / `mockupstreamb`), failover past a dead primary,
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build dockere2e
|
||||
|
||||
package e2edocker
|
||||
|
||||
func init() {
|
||||
cachingCases = append(cachingCases, cachingCase{"alpine", "alpine/x86_64/testpkg-1.0-r0.apk", "alpine/x86_64/testpkg-1.0-r0.apk"})
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build dockere2e
|
||||
|
||||
package e2edocker
|
||||
|
||||
func init() {
|
||||
cachingCases = append(cachingCases, cachingCase{"cargo", "crates/mycrate/mycrate-1.0.0.crate", "crates/mycrate/mycrate-1.0.0.crate"})
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build dockere2e
|
||||
|
||||
package e2edocker
|
||||
|
||||
func init() {
|
||||
cachingCases = append(cachingCases, cachingCase{"docker", "library/testimg/blobs/blobdata", "v2/library/testimg/blobs/blobdata"})
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build dockere2e
|
||||
|
||||
package e2edocker
|
||||
|
||||
func init() {
|
||||
cachingCases = append(cachingCases, cachingCase{"generic", "blobs/hello.bin", "blobs/hello.bin"})
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build dockere2e
|
||||
|
||||
package e2edocker
|
||||
|
||||
func init() {
|
||||
cachingCases = append(cachingCases, cachingCase{"goproxy", "goproxy/example.com/mod/@v/v1.0.0.zip", "goproxy/example.com/mod/@v/v1.0.0.zip"})
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build dockere2e
|
||||
|
||||
package e2edocker
|
||||
|
||||
func init() {
|
||||
cachingCases = append(cachingCases, cachingCase{"helm", "charts/mychart-1.0.0.tgz", "charts/mychart-1.0.0.tgz"})
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build dockere2e
|
||||
|
||||
package e2edocker
|
||||
|
||||
func init() {
|
||||
cachingCases = append(cachingCases, cachingCase{"npm", "mypkg/-/mypkg-1.0.0.tgz", "mypkg/-/mypkg-1.0.0.tgz"})
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build dockere2e
|
||||
|
||||
package e2edocker
|
||||
|
||||
func init() {
|
||||
cachingCases = append(cachingCases, cachingCase{"nuget", "nuget/e2epkg/1.0.0/e2epkg.1.0.0.nupkg", "nuget/e2epkg/1.0.0/e2epkg.1.0.0.nupkg"})
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build dockere2e
|
||||
|
||||
package e2edocker
|
||||
|
||||
func init() {
|
||||
cachingCases = append(cachingCases, cachingCase{"puppet", "puppet-releases/author-mod-1.0.0.tar.gz", "puppet-releases/author-mod-1.0.0.tar.gz"})
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build dockere2e
|
||||
|
||||
package e2edocker
|
||||
|
||||
func init() {
|
||||
cachingCases = append(cachingCases, cachingCase{"pypi", "packages/foo-1.0-py3-none-any.whl", "packages/foo-1.0-py3-none-any.whl"})
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build dockere2e
|
||||
|
||||
package e2edocker
|
||||
|
||||
func init() {
|
||||
cachingCases = append(cachingCases, cachingCase{"rpm", "rpmrepo/Packages/e2e-testpkg-1.0-1.noarch.rpm", "rpmrepo/Packages/e2e-testpkg-1.0-1.noarch.rpm"})
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build dockere2e
|
||||
|
||||
package e2edocker
|
||||
|
||||
func init() {
|
||||
cachingCases = append(cachingCases, cachingCase{"terraform", "hashicorp/aws/download/pkg.zip", "v1/providers/hashicorp/aws/download/pkg.zip"})
|
||||
}
|
||||
+13
-23
@@ -9,33 +9,23 @@ import (
|
||||
"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
|
||||
// package type through the mock upstream and asserts: first fetch is served
|
||||
// from the remote, the second from cache, and the bytes match the origin.
|
||||
func TestCachingPerProvider(t *testing.T) {
|
||||
cases := []struct {
|
||||
pkgType string
|
||||
// path is the request path under /api/v1/remote/<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 {
|
||||
for _, tc := range cachingCases {
|
||||
t.Run(tc.pkgType, func(t *testing.T) {
|
||||
name := "cache-" + tc.pkgType
|
||||
createRepo(t, fmt.Sprintf(`{
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
//go:build dockere2e
|
||||
|
||||
package e2edocker
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestLocalHelmRealClient packages a chart with the real helm CLI, publishes it
|
||||
// to a helm local, then drives helm repo add/search/pull/template against the
|
||||
// local and against a helm virtual that has the local as a member.
|
||||
func TestLocalHelmRealClient(t *testing.T) {
|
||||
network := os.Getenv("COMPOSE_NETWORK")
|
||||
internal := strings.TrimRight(os.Getenv("ARTIFACTAPI_INTERNAL"), "/")
|
||||
if network == "" || internal == "" {
|
||||
t.Skip("COMPOSE_NETWORK/ARTIFACTAPI_INTERNAL not set; run via scripts/docker-e2e.sh")
|
||||
}
|
||||
if _, err := exec.LookPath("docker"); err != nil {
|
||||
t.Skip("docker not available on the test host")
|
||||
}
|
||||
|
||||
createRepo(t, `{"name":"local-helm","package_type":"helm","repo_type":"local"}`)
|
||||
defer deleteRepo(t, "local-helm")
|
||||
createVirtual(t, `{"name":"vhelm","package_type":"helm","members":["local-helm"]}`)
|
||||
defer deleteVirtual(t, "vhelm")
|
||||
|
||||
helm := func(script string) string {
|
||||
t.Helper()
|
||||
out, err := exec.Command("docker", "run", "--rm", "--network", network, "--entrypoint", "sh",
|
||||
"alpine/helm:3.16.4", "-c", "set -eu; "+script).Output()
|
||||
if err != nil {
|
||||
stderr := ""
|
||||
if ee, ok := err.(*exec.ExitError); ok {
|
||||
stderr = string(ee.Stderr)
|
||||
}
|
||||
t.Fatalf("helm script failed: %v\n%s%s", err, out, stderr)
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
chart := helm("cd /tmp && helm create e2echart >/dev/null && helm package e2echart >/dev/null && cat e2echart-0.1.0.tgz")
|
||||
uploadFile(t, "local-helm", "e2echart-0.1.0.tgz", []byte(chart), "application/gzip")
|
||||
|
||||
// Chart metadata is recorded asynchronously after upload; poll the index.
|
||||
var body []byte
|
||||
for deadline := time.Now().Add(15 * time.Second); time.Now().Before(deadline); time.Sleep(500 * time.Millisecond) {
|
||||
var resp *http.Response
|
||||
resp, body = doRequest(t, http.MethodGet, api("/api/v1/local/local-helm/index.yaml"), nil, "")
|
||||
if resp.StatusCode == http.StatusOK && strings.Contains(string(body), "e2echart-0.1.0.tgz") {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !strings.Contains(string(body), "e2echart-0.1.0.tgz") || !strings.Contains(string(body), "digest: ") {
|
||||
t.Fatalf("index.yaml never listed the chart:\n%s", body)
|
||||
}
|
||||
|
||||
for _, repoURL := range []string{internal + "/api/v1/local/local-helm", internal + "/api/v1/virtual/vhelm"} {
|
||||
out := helm("helm repo add e2e " + repoURL + " >/dev/null && " +
|
||||
"helm search repo e2e/e2echart && " +
|
||||
"cd /tmp && helm pull e2e/e2echart --version 0.1.0 && " +
|
||||
"helm template rel e2echart-0.1.0.tgz | grep -c 'kind: Deployment'")
|
||||
if !strings.Contains(out, "e2e/e2echart") || !strings.Contains(out, "0.1.0") || !strings.HasSuffix(strings.TrimSpace(out), "1") {
|
||||
t.Fatalf("helm via %s:\n%s", repoURL, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
//go:build dockere2e
|
||||
|
||||
package e2edocker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestLocalNPMPublishInstall drives a stock npm client against a local npm
|
||||
// repo: publish unscoped and scoped packages, install and require() them in a
|
||||
// fresh project, publish a second version, and reject a republish.
|
||||
func TestLocalNPMPublishInstall(t *testing.T) {
|
||||
network := os.Getenv("COMPOSE_NETWORK")
|
||||
internal := os.Getenv("ARTIFACTAPI_INTERNAL")
|
||||
if network == "" || internal == "" {
|
||||
t.Skip("COMPOSE_NETWORK/ARTIFACTAPI_INTERNAL not set; run via scripts/docker-e2e.sh")
|
||||
}
|
||||
if _, err := exec.LookPath("docker"); err != nil {
|
||||
t.Skip("docker not available on the test host")
|
||||
}
|
||||
|
||||
name := fmt.Sprintf("e2e-npm-local-%d", time.Now().UnixNano())
|
||||
createRepo(t, fmt.Sprintf(`{"name":%q,"package_type":"npm","repo_type":"local"}`, name))
|
||||
defer deleteRepo(t, name)
|
||||
|
||||
reg := strings.TrimRight(internal, "/") + "/api/v1/local/" + name + "/"
|
||||
script := `set -euo pipefail
|
||||
printf 'registry=%s\n%s:_authToken=unused\n' "$REG" "${REG#http:}" > ~/.npmrc
|
||||
mk() { d="/w/src/$1-$2"; mkdir -p "$d"; cd "$d"
|
||||
printf '{"name":"%s","version":"%s","main":"index.js"}' "$1" "$2" > package.json
|
||||
echo "module.exports = '$1@$2';" > index.js; }
|
||||
mk e2e-npm-plain 1.0.0 && npm publish
|
||||
mk @e2e/npm-scoped 1.0.0 && npm publish
|
||||
mk e2e-npm-plain 1.0.0
|
||||
if npm publish 2>/dev/null; then echo "republish accepted"; exit 1; fi
|
||||
mkdir -p /w/app && cd /w/app && npm init -y >/dev/null
|
||||
npm install --no-audit --cache /tmp/fresh-cache e2e-npm-plain @e2e/npm-scoped
|
||||
node -e "console.log('REQ', require('e2e-npm-plain'), require('@e2e/npm-scoped'))"
|
||||
mk e2e-npm-plain 1.1.0 && npm publish
|
||||
echo "VERSIONS $(npm view e2e-npm-plain versions --json | tr -d ' \n')"
|
||||
echo "LATEST $(npm view e2e-npm-plain dist-tags.latest)"`
|
||||
|
||||
out, err := exec.Command("docker", "run", "--rm", "--network", network,
|
||||
"-e", "REG="+reg, "node:22", "bash", "-c", script).CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("npm client run failed: %v\n%s", err, out)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"REQ e2e-npm-plain@1.0.0 @e2e/npm-scoped@1.0.0",
|
||||
`VERSIONS ["1.0.0","1.1.0"]`,
|
||||
"LATEST 1.1.0",
|
||||
} {
|
||||
if !strings.Contains(string(out), want) {
|
||||
t.Fatalf("missing %q in npm output:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
//go:build dockere2e
|
||||
|
||||
package e2edocker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestVirtualNPMInstall publishes to two local npm repos and drives a stock
|
||||
// npm client through a virtual over both: merged versions and dist-tags, and
|
||||
// an install whose tarballs resolve via the virtual's member redirect.
|
||||
func TestVirtualNPMInstall(t *testing.T) {
|
||||
network := os.Getenv("COMPOSE_NETWORK")
|
||||
internal := os.Getenv("ARTIFACTAPI_INTERNAL")
|
||||
if network == "" || internal == "" {
|
||||
t.Skip("COMPOSE_NETWORK/ARTIFACTAPI_INTERNAL not set; run via scripts/docker-e2e.sh")
|
||||
}
|
||||
if _, err := exec.LookPath("docker"); err != nil {
|
||||
t.Skip("docker not available on the test host")
|
||||
}
|
||||
|
||||
id := time.Now().UnixNano()
|
||||
a, b, virt := fmt.Sprintf("e2e-npm-a-%d", id), fmt.Sprintf("e2e-npm-b-%d", id), fmt.Sprintf("e2e-npm-v-%d", id)
|
||||
for _, name := range []string{a, b} {
|
||||
createRepo(t, fmt.Sprintf(`{"name":%q,"package_type":"npm","repo_type":"local"}`, name))
|
||||
defer deleteRepo(t, name)
|
||||
}
|
||||
createVirtual(t, fmt.Sprintf(`{"name":%q,"package_type":"npm","members":[%q,%q]}`, virt, a, b))
|
||||
defer deleteVirtual(t, virt)
|
||||
|
||||
base := strings.TrimRight(internal, "/")
|
||||
script := `set -euo pipefail
|
||||
pub() { d="/w/src/$2-$3-$1"; mkdir -p "$d"; cd "$d"
|
||||
printf '{"name":"%s","version":"%s","main":"index.js"}' "$2" "$3" > package.json
|
||||
echo "module.exports = '$1:$2@$3';" > index.js
|
||||
printf 'registry=%s\n%s:_authToken=unused\n' "$BASE/api/v1/local/$1/" "${BASE#http:}/api/v1/local/$1/" > .npmrc
|
||||
npm publish; }
|
||||
pub "$A" @e2e/virt 1.0.0
|
||||
pub "$B" @e2e/virt 1.0.0
|
||||
pub "$B" @e2e/virt 1.1.0
|
||||
mkdir -p /w/app && cd /w/app && npm init -y >/dev/null
|
||||
echo "registry=$BASE/api/v1/virtual/$V/" > .npmrc
|
||||
echo "VERSIONS $(npm view @e2e/virt versions --json | tr -d ' \n')"
|
||||
npm install --no-audit --cache /tmp/fresh-cache @e2e/virt@1.0.0
|
||||
node -e "console.log('REQ', require('@e2e/virt'))"
|
||||
echo "RESOLVED $(node -p "require('./package-lock.json').packages['node_modules/@e2e/virt'].resolved")"`
|
||||
|
||||
out, err := exec.Command("docker", "run", "--rm", "--network", network,
|
||||
"-e", "BASE="+base, "-e", "A="+a, "-e", "B="+b, "-e", "V="+virt,
|
||||
"node:22", "bash", "-c", script).CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("npm client run failed: %v\n%s", err, out)
|
||||
}
|
||||
for _, want := range []string{
|
||||
`VERSIONS ["1.0.0","1.1.0"]`,
|
||||
"REQ " + a + ":@e2e/virt@1.0.0",
|
||||
"RESOLVED " + base + "/api/v1/virtual/" + virt + "/" + a + "/@e2e/virt/-/virt-1.0.0.tgz",
|
||||
} {
|
||||
if !strings.Contains(string(out), want) {
|
||||
t.Fatalf("missing %q in npm output:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
//go:build dockere2e
|
||||
|
||||
package e2edocker
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const puppetHelloMetadata = `{"name":"unkin-hello","version":"0.1.0","author":"unkin","summary":"e2e","license":"Apache-2.0","source":"https://example.invalid","dependencies":[{"name":"puppetlabs/stdlib","version_requirement":">= 4.0.0 < 10.0.0"}]}`
|
||||
|
||||
func puppetModuleTarball(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
zw := gzip.NewWriter(&buf)
|
||||
tw := tar.NewWriter(zw)
|
||||
for name, body := range map[string]string{
|
||||
"unkin-hello-0.1.0/metadata.json": puppetHelloMetadata,
|
||||
"unkin-hello-0.1.0/manifests/init.pp": "class hello {}\n",
|
||||
} {
|
||||
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: int64(len(body)), ModTime: time.Now()}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = tw.Write([]byte(body))
|
||||
}
|
||||
_ = tw.Close()
|
||||
_ = zw.Close()
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// TestLocalPuppetRealClient publishes a module to a puppet local and installs
|
||||
// it with the real r10k and puppet module tool. The stdlib dependency is not on
|
||||
// a local-only forge, so puppet runs with --ignore-dependencies.
|
||||
func TestLocalPuppetRealClient(t *testing.T) {
|
||||
network := os.Getenv("COMPOSE_NETWORK")
|
||||
internal := strings.TrimRight(os.Getenv("ARTIFACTAPI_INTERNAL"), "/")
|
||||
if network == "" || internal == "" {
|
||||
t.Skip("COMPOSE_NETWORK/ARTIFACTAPI_INTERNAL not set; run via scripts/docker-e2e.sh")
|
||||
}
|
||||
if _, err := exec.LookPath("docker"); err != nil {
|
||||
t.Skip("docker not available on the test host")
|
||||
}
|
||||
|
||||
createRepo(t, `{"name":"local-puppet","package_type":"puppet","repo_type":"local"}`)
|
||||
defer deleteRepo(t, "local-puppet")
|
||||
|
||||
upload := api("/api/v2/remotes/local-puppet/files/unkin-hello-0.1.0.tar.gz")
|
||||
defer doRequest(t, http.MethodDelete, api("/api/v2/remotes/local-puppet/files/v3/files/unkin-hello-0.1.0.tar.gz"), nil, "")
|
||||
tarball := puppetModuleTarball(t)
|
||||
if resp, body := doRequest(t, http.MethodPut, upload, tarball, "application/gzip"); resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("upload: %d %s", resp.StatusCode, body)
|
||||
}
|
||||
if resp, _ := doRequest(t, http.MethodPut, upload, tarball, "application/gzip"); resp.StatusCode != http.StatusConflict {
|
||||
t.Fatalf("re-upload of an existing version: %d, want 409", resp.StatusCode)
|
||||
}
|
||||
if resp, body := getEventually(t, api("/api/v1/local/local-puppet/v3/releases/unkin-hello-0.1.0"), 15*time.Second); resp.StatusCode != http.StatusOK || !strings.Contains(string(body), `"file_md5"`) {
|
||||
t.Fatalf("release never indexed: %d %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
forge := internal + "/api/v1/local/local-puppet"
|
||||
run := func(image, script string) string {
|
||||
t.Helper()
|
||||
out, err := exec.Command("docker", "run", "--rm", "--network", network, "--entrypoint", "sh", image, "-c", "set -eu; "+script).CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("%s failed: %v\n%s", image, err, out)
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
out := run("ghcr.io/voxpupuli/r10k:latest", "cd /tmp && printf 'forge:\\n baseurl: "+forge+"\\n' > r10k.yaml && "+
|
||||
"printf \"forge '"+forge+"'\\nmod 'unkin-hello', '0.1.0'\\n\" > Puppetfile && "+
|
||||
"r10k puppetfile install -c r10k.yaml --verbose && cat modules/hello/metadata.json")
|
||||
if !strings.Contains(out, `"unkin-hello"`) {
|
||||
t.Fatalf("r10k did not install unkin-hello:\n%s", out)
|
||||
}
|
||||
|
||||
out = run("ghcr.io/openvoxproject/openvoxagent:latest", "export PATH=/opt/puppetlabs/bin:$PATH; puppet module install unkin-hello --module_repository "+forge+" --ignore-dependencies --target-dir /tmp/mods && puppet module list --color=false --modulepath /tmp/mods")
|
||||
if !strings.Contains(out, "unkin-hello (v0.1.0)") {
|
||||
t.Fatalf("puppet module install did not install unkin-hello:\n%s", out)
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,6 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/testsupport"
|
||||
)
|
||||
|
||||
// TestVirtualPyPIMerge uploads different packages to two pypi locals and
|
||||
@@ -138,104 +136,3 @@ func TestVirtualRPMMerge(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
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
|
||||
}
|
||||
read := func(hash string) ([]byte, error) { return h.readLocalBlob(r.Context(), hash) }
|
||||
tarballBase := fmt.Sprintf("%s://%s/api/v1/local/%s/", scheme(r), r.Host, 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
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("X-Artifact-Source", "local")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(doc)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
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/database"
|
||||
"git.unkin.net/unkin/artifactapi/internal/storage"
|
||||
"git.unkin.net/unkin/artifactapi/internal/testsupport"
|
||||
"git.unkin.net/unkin/artifactapi/pkg/models"
|
||||
)
|
||||
|
||||
var (
|
||||
testDSN string
|
||||
testMinio testsupport.MinioConn
|
||||
)
|
||||
|
||||
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())
|
||||
}
|
||||
testDSN, testMinio = dsn, conn
|
||||
code := m.Run()
|
||||
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)
|
||||
}
|
||||
h := NewProxyHandler(nil, 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"crypto/sha1"
|
||||
"crypto/sha512"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/database"
|
||||
)
|
||||
|
||||
func TestNPMPackageName(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"left-pad": "left-pad",
|
||||
"left-pad/": "left-pad",
|
||||
"@acme%2fwidget": "@acme/widget",
|
||||
"@acme%2Fwidget": "@acme/widget",
|
||||
"@acme/widget": "@acme/widget",
|
||||
"left-pad/-/left-pad.tgz": "",
|
||||
"@acme/widget/-/w-1.tgz": "",
|
||||
"a/b": "",
|
||||
"../etc": "",
|
||||
"Upper": "",
|
||||
"": "",
|
||||
}
|
||||
for in, want := range tests {
|
||||
if got := npmPackageName(in); got != want {
|
||||
t.Errorf("npmPackageName(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type publishOpts struct {
|
||||
bodyName, manifestName, version, integrity, shasum string
|
||||
tags map[string]string
|
||||
extraVersion bool
|
||||
}
|
||||
|
||||
func publishBody(t *testing.T, pkg string, tgz []byte, o publishOpts) string {
|
||||
t.Helper()
|
||||
sha := sha512.Sum512(tgz)
|
||||
sh1 := sha1.Sum(tgz)
|
||||
if o.bodyName == "" {
|
||||
o.bodyName = pkg
|
||||
}
|
||||
if o.manifestName == "" {
|
||||
o.manifestName = pkg
|
||||
}
|
||||
if o.version == "" {
|
||||
o.version = "1.0.0"
|
||||
}
|
||||
if o.integrity == "" {
|
||||
o.integrity = "sha512-" + base64.StdEncoding.EncodeToString(sha[:])
|
||||
}
|
||||
if o.shasum == "" {
|
||||
o.shasum = hex.EncodeToString(sh1[:])
|
||||
}
|
||||
if o.tags == nil {
|
||||
o.tags = map[string]string{"latest": o.version}
|
||||
}
|
||||
versions := map[string]any{o.version: map[string]any{
|
||||
"name": o.manifestName, "version": o.version, "main": "index.js",
|
||||
"dist": map[string]any{"integrity": o.integrity, "shasum": o.shasum, "tarball": "http://elsewhere/x.tgz"},
|
||||
}}
|
||||
if o.extraVersion {
|
||||
versions["2.0.0"] = map[string]any{"name": pkg, "version": "2.0.0"}
|
||||
}
|
||||
b, _ := json.Marshal(map[string]any{
|
||||
"_id": pkg, "name": o.bodyName, "dist-tags": o.tags, "versions": versions,
|
||||
"_attachments": map[string]any{pkg + "-" + o.version + ".tgz": map[string]any{
|
||||
"content_type": "application/octet-stream", "data": base64.StdEncoding.EncodeToString(tgz), "length": len(tgz),
|
||||
}},
|
||||
})
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func TestParseNPMPublish(t *testing.T) {
|
||||
tgz := []byte("fake tarball bytes")
|
||||
|
||||
rel, err := parseNPMPublish(strings.NewReader(publishBody(t, "@acme/widget", tgz, publishOpts{tags: map[string]string{"latest": "1.0.0", "beta": "1.0.0"}})), "@acme/widget")
|
||||
if err != nil {
|
||||
t.Fatalf("valid scoped publish rejected: %v", err)
|
||||
}
|
||||
if rel.version != "1.0.0" || string(rel.tarball) != string(tgz) || len(rel.tags) != 2 {
|
||||
t.Fatalf("unexpected release: %+v", rel)
|
||||
}
|
||||
|
||||
if _, err := parseNPMPublish(strings.NewReader(publishBody(t, "left-pad", tgz, publishOpts{integrity: "none", shasum: hex.EncodeToString(make([]byte, 20))})), "left-pad"); err == nil {
|
||||
t.Fatal("shasum fallback should reject a mismatched tarball")
|
||||
}
|
||||
if _, err := parseNPMPublish(strings.NewReader(publishBody(t, "left-pad", tgz, publishOpts{integrity: "none"})), "left-pad"); err != nil {
|
||||
t.Fatalf("matching shasum fallback rejected: %v", err)
|
||||
}
|
||||
|
||||
bad := map[string]publishOpts{
|
||||
"body name mismatch": {bodyName: "other"},
|
||||
"manifest name mismatch": {manifestName: "other"},
|
||||
"integrity mismatch": {integrity: "sha512-AAAA"},
|
||||
"path-like version": {version: "../1.0.0"},
|
||||
"non-semver version": {version: "1..2"},
|
||||
"leading-zero version": {version: "01.0.0"},
|
||||
"tag at another version": {tags: map[string]string{"latest": "9.9.9"}},
|
||||
"path-like tag": {tags: map[string]string{"../x": "1.0.0"}},
|
||||
"multiple versions at once": {extraVersion: true},
|
||||
}
|
||||
for name, o := range bad {
|
||||
if _, err := parseNPMPublish(strings.NewReader(publishBody(t, "left-pad", tgz, o)), "left-pad"); err == nil {
|
||||
t.Errorf("%s: expected rejection", name)
|
||||
}
|
||||
}
|
||||
if _, err := parseNPMPublish(strings.NewReader(`{"name":"left-pad","versions":{}}`), "left-pad"); err == nil {
|
||||
t.Error("deprecate-style body without attachments should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNPMPackument(t *testing.T) {
|
||||
blobs := map[string]string{
|
||||
"h1": `{"name":"my_pkg","version":"1.0.0","dist":{"shasum":"aa","tarball":"http://elsewhere/x.tgz"}}`,
|
||||
"h2": `{"name":"my_pkg","version":"1.1.0"}`,
|
||||
"h3": "1.1.0",
|
||||
"h4": "2.0.0",
|
||||
"hx": `{"name":"myXpkg","version":"6.6.6"}`,
|
||||
}
|
||||
read := func(h string) ([]byte, error) {
|
||||
b, ok := blobs[h]
|
||||
if !ok {
|
||||
return nil, errors.New("missing blob")
|
||||
}
|
||||
return []byte(b), nil
|
||||
}
|
||||
files := []database.LocalFile{
|
||||
{FilePath: "my_pkg/-/dist-tags/latest", ContentHash: "h3"},
|
||||
{FilePath: "my_pkg/-/dist-tags/next", ContentHash: "h4"},
|
||||
{FilePath: "my_pkg/-/my_pkg-1.0.0.json", ContentHash: "h1"},
|
||||
{FilePath: "my_pkg/-/my_pkg-1.0.0.tgz", ContentHash: "nope"},
|
||||
{FilePath: "my_pkg/-/my_pkg-1.1.0.json", ContentHash: "h2"},
|
||||
{FilePath: "myXpkg/-/myXpkg-6.6.6.json", ContentHash: "hx"},
|
||||
}
|
||||
|
||||
doc, err := buildNPMPackument("my_pkg", files, read, "http://reg/api/v1/local/npm/")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tags := doc["dist-tags"].(map[string]string); tags["latest"] != "1.1.0" || len(tags) != 1 {
|
||||
t.Errorf("dist-tags = %v", tags)
|
||||
}
|
||||
versions := doc["versions"].(map[string]any)
|
||||
if len(versions) != 2 {
|
||||
t.Fatalf("versions = %v (LIKE over-match must be dropped)", versions)
|
||||
}
|
||||
v1 := versions["1.0.0"].(map[string]any)["dist"].(map[string]any)
|
||||
if v1["tarball"] != "http://reg/api/v1/local/npm/my_pkg/-/my_pkg-1.0.0.tgz" || v1["shasum"] != "aa" {
|
||||
t.Errorf("1.0.0 dist = %v", v1)
|
||||
}
|
||||
if versions["1.1.0"].(map[string]any)["dist"].(map[string]any)["tarball"] != "http://reg/api/v1/local/npm/my_pkg/-/my_pkg-1.1.0.tgz" {
|
||||
t.Error("1.1.0 missing rewritten tarball")
|
||||
}
|
||||
|
||||
if doc, err := buildNPMPackument("absent", files, read, "x/"); err != nil || doc != nil {
|
||||
t.Errorf("absent package: doc=%v err=%v", doc, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/virtual"
|
||||
)
|
||||
|
||||
// LocalNPMPackument serves a local npm repo's packument to npm virtuals, with
|
||||
// tarball paths relative to the repo.
|
||||
func (h *ProxyHandler) LocalNPMPackument(ctx context.Context, repo, pkg string) ([]byte, error) {
|
||||
files, err := h.db.ListLocalFilesByPrefix(ctx, repo, pkg+"/-/")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
read := func(hash string) ([]byte, error) { return h.readLocalBlob(ctx, hash) }
|
||||
doc, err := buildNPMPackument(pkg, files, read, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if doc == nil {
|
||||
return nil, virtual.ErrNotFound
|
||||
}
|
||||
return json.Marshal(doc)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
@@ -16,6 +17,7 @@ import (
|
||||
"git.unkin.net/unkin/artifactapi/internal/proxy"
|
||||
"git.unkin.net/unkin/artifactapi/internal/storage"
|
||||
"git.unkin.net/unkin/artifactapi/internal/virtual"
|
||||
"git.unkin.net/unkin/artifactapi/pkg/models"
|
||||
)
|
||||
|
||||
type ProxyHandler struct {
|
||||
@@ -42,6 +44,7 @@ func (h *ProxyHandler) Routes() chi.Router {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/remote/{remoteName}/*", h.handleProxy)
|
||||
r.Get("/local/{localName}/*", h.handleLocal)
|
||||
r.Put("/local/{localName}/*", h.handleLocalPut)
|
||||
r.Get("/virtual/{virtualName}/*", h.handleVirtual)
|
||||
return r
|
||||
}
|
||||
@@ -184,7 +187,14 @@ func (h *ProxyHandler) handleVirtual(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
body, contentType, err := h.virtualEngine.Fetch(r.Context(), *virt, path, proxyBaseURL)
|
||||
var body []byte
|
||||
var contentType string
|
||||
if virt.PackageType == models.PackageNPM {
|
||||
abbreviated := strings.Contains(r.Header.Get("Accept"), virtual.NPMAbbreviatedType)
|
||||
body, contentType, err = h.virtualEngine.FetchNPM(r.Context(), *virt, path, proxyBaseURL, abbreviated)
|
||||
} else {
|
||||
body, contentType, err = h.virtualEngine.Fetch(r.Context(), *virt, path, proxyBaseURL)
|
||||
}
|
||||
if errors.Is(err, virtual.ErrNotFound) {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
@@ -211,6 +221,13 @@ func (h *ProxyHandler) handleLocal(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if remote.PackageType == models.PackageNPM {
|
||||
if pkg := npmPackageName(path); pkg != "" {
|
||||
h.serveNPMPackument(w, r, remote, pkg)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
prov, _ := provider.Get(remote.PackageType)
|
||||
if indexer, ok := prov.(provider.LocalIndexer); ok {
|
||||
if indexer.ServeLocalIndex(w, r, h.db, remote.Name, path) {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package all
|
||||
|
||||
import _ "git.unkin.net/unkin/artifactapi/internal/provider/alpine"
|
||||
@@ -0,0 +1,3 @@
|
||||
package all
|
||||
|
||||
import _ "git.unkin.net/unkin/artifactapi/internal/provider/cargo"
|
||||
@@ -0,0 +1,3 @@
|
||||
package all
|
||||
|
||||
import _ "git.unkin.net/unkin/artifactapi/internal/provider/deb"
|
||||
@@ -0,0 +1,3 @@
|
||||
package all
|
||||
|
||||
import _ "git.unkin.net/unkin/artifactapi/internal/provider/docker"
|
||||
@@ -0,0 +1,3 @@
|
||||
package all
|
||||
|
||||
import _ "git.unkin.net/unkin/artifactapi/internal/provider/generic"
|
||||
@@ -0,0 +1,3 @@
|
||||
package all
|
||||
|
||||
import _ "git.unkin.net/unkin/artifactapi/internal/provider/goproxy"
|
||||
@@ -0,0 +1,3 @@
|
||||
package all
|
||||
|
||||
import _ "git.unkin.net/unkin/artifactapi/internal/provider/helm"
|
||||
@@ -0,0 +1,3 @@
|
||||
package all
|
||||
|
||||
import _ "git.unkin.net/unkin/artifactapi/internal/provider/npm"
|
||||
@@ -0,0 +1,3 @@
|
||||
package all
|
||||
|
||||
import _ "git.unkin.net/unkin/artifactapi/internal/provider/nuget"
|
||||
@@ -0,0 +1,3 @@
|
||||
package all
|
||||
|
||||
import _ "git.unkin.net/unkin/artifactapi/internal/provider/puppet"
|
||||
@@ -0,0 +1,3 @@
|
||||
package all
|
||||
|
||||
import _ "git.unkin.net/unkin/artifactapi/internal/provider/pypi"
|
||||
@@ -0,0 +1,3 @@
|
||||
package all
|
||||
|
||||
import _ "git.unkin.net/unkin/artifactapi/internal/provider/rpm"
|
||||
@@ -0,0 +1,3 @@
|
||||
package all
|
||||
|
||||
import _ "git.unkin.net/unkin/artifactapi/internal/provider/terraform"
|
||||
@@ -0,0 +1,15 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package provider
|
||||
|
||||
type IndexMerger interface {
|
||||
MergeIndexes(members []MemberIndex, proxyBaseURL string) ([]byte, error)
|
||||
}
|
||||
|
||||
type MemberIndex struct {
|
||||
RemoteName string
|
||||
Body []byte
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package provider
|
||||
|
||||
type LocalUploader interface {
|
||||
ValidateUpload(filePath string) (storagePath, contentType string, err error)
|
||||
UploadResponse(storagePath, contentHash string, sizeBytes int64) map[string]any
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
type PostUploadHook interface {
|
||||
AfterUpload(ctx context.Context, repoName, storagePath, contentHash string, blobs BlobReader, db MetadataStore)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
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
|
||||
}
|
||||
@@ -1,12 +1,24 @@
|
||||
package helm
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/auth"
|
||||
"git.unkin.net/unkin/artifactapi/internal/provider"
|
||||
"git.unkin.net/unkin/artifactapi/internal/storage"
|
||||
"git.unkin.net/unkin/artifactapi/pkg/models"
|
||||
)
|
||||
|
||||
@@ -56,3 +68,203 @@ func (p *Provider) RewriteResponse(body []byte, remote models.Remote, proxyBaseU
|
||||
func (p *Provider) AuthHeaders(_ context.Context, remote models.Remote) (http.Header, error) {
|
||||
return auth.BasicHeaders(remote), nil
|
||||
}
|
||||
|
||||
// ValidateUpload stores charts flat by filename so index.yaml can reference
|
||||
// them with relative urls, as `helm repo index` does.
|
||||
func (p *Provider) ValidateUpload(filePath string) (storagePath, contentType string, err error) {
|
||||
filename := path.Base(path.Clean("/" + filePath))
|
||||
if !strings.HasSuffix(filename, ".tgz") || filename == ".tgz" {
|
||||
return "", "", fmt.Errorf("file must be a .tgz chart package")
|
||||
}
|
||||
return filename, "application/gzip", nil
|
||||
}
|
||||
|
||||
func (p *Provider) UploadResponse(storagePath, contentHash string, sizeBytes int64) map[string]any {
|
||||
return map[string]any{
|
||||
"filename": storagePath,
|
||||
"content_hash": contentHash,
|
||||
"size_bytes": sizeBytes,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Provider) AfterUpload(ctx context.Context, repoName, storagePath, contentHash string, blobs provider.BlobReader, db provider.MetadataStore) {
|
||||
reader, _, err := blobs.Download(ctx, storage.BlobKey(strings.TrimPrefix(contentHash, "sha256:")))
|
||||
if err != nil {
|
||||
slog.Error("helm metadata: download failed", "repo", repoName, "path", storagePath, "error", err)
|
||||
return
|
||||
}
|
||||
defer func() { _ = reader.Close() }()
|
||||
|
||||
meta, err := parseChart(reader)
|
||||
if err != nil {
|
||||
slog.Error("helm metadata: parse failed", "repo", repoName, "path", storagePath, "error", err)
|
||||
return
|
||||
}
|
||||
if want := meta.Name + "-" + meta.Version + ".tgz"; storagePath != want {
|
||||
slog.Error("helm metadata: filename does not match chart", "repo", repoName, "path", storagePath, "expected", want)
|
||||
return
|
||||
}
|
||||
meta.RepoName = repoName
|
||||
meta.FilePath = storagePath
|
||||
meta.ContentHash = contentHash
|
||||
|
||||
store, ok := db.(provider.HelmMetadataStore)
|
||||
if !ok {
|
||||
slog.Error("helm metadata: store does not support helm metadata", "repo", repoName)
|
||||
return
|
||||
}
|
||||
if err := store.InsertHelmMetadata(ctx, meta); err != nil {
|
||||
slog.Error("helm metadata: insert failed", "repo", repoName, "path", storagePath, "error", err)
|
||||
return
|
||||
}
|
||||
slog.Info("helm metadata: parsed", "repo", repoName, "name", meta.Name, "version", meta.Version)
|
||||
}
|
||||
|
||||
func (p *Provider) AfterDelete(ctx context.Context, repoName, storagePath string, db provider.MetadataDeleter) error {
|
||||
deleter, ok := db.(provider.HelmMetadataDeleter)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return deleter.DeleteHelmMetadata(ctx, repoName, storagePath)
|
||||
}
|
||||
|
||||
func isIndexPath(p string) bool {
|
||||
return strings.TrimPrefix(path.Clean("/"+p), "/") == "index.yaml"
|
||||
}
|
||||
|
||||
func (p *Provider) ServeLocalIndex(w http.ResponseWriter, r *http.Request, files provider.FileStore, repoName, reqPath string) bool {
|
||||
if !isIndexPath(reqPath) {
|
||||
return false
|
||||
}
|
||||
body, err := p.GenerateLocalIndex(r.Context(), files, repoName, reqPath)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return true
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/yaml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(body)
|
||||
return true
|
||||
}
|
||||
|
||||
func (p *Provider) GenerateLocalIndex(ctx context.Context, files provider.FileStore, repoName, reqPath string) ([]byte, error) {
|
||||
if !isIndexPath(reqPath) {
|
||||
return nil, fmt.Errorf("unsupported index path: %q", reqPath)
|
||||
}
|
||||
reader, ok := files.(provider.HelmMetadataReader)
|
||||
if !ok {
|
||||
return nil, errors.New("helm metadata not available")
|
||||
}
|
||||
metas, err := reader.ListHelmMetadataEntries(ctx, repoName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return generateIndex(metas)
|
||||
}
|
||||
|
||||
// generateIndex builds index.yaml from stored Chart.yaml documents; it is
|
||||
// stamped from the newest upload so every replica serves identical bytes.
|
||||
func generateIndex(metas []provider.HelmMetadata) ([]byte, error) {
|
||||
entries := map[string][]map[string]yaml.Node{}
|
||||
var newest time.Time
|
||||
budget := provider.MaxAliasExpandedNodes
|
||||
for _, m := range metas {
|
||||
var raw map[string]yaml.Node
|
||||
if err := yaml.Unmarshal([]byte(m.ChartYAML), &raw); err != nil {
|
||||
return nil, fmt.Errorf("chart %s: %w", m.FilePath, err)
|
||||
}
|
||||
chart, err := provider.ExpandYAMLAliases(raw, &budget)
|
||||
if err != nil {
|
||||
slog.Warn("helm index: dropping chart", "chart", m.Name, "path", m.FilePath, "error", err)
|
||||
continue
|
||||
}
|
||||
chart["name"] = node(m.Name)
|
||||
chart["version"] = node(m.Version)
|
||||
chart["urls"] = node([]string{url.PathEscape(m.FilePath)})
|
||||
chart["digest"] = node(strings.TrimPrefix(m.ContentHash, "sha256:"))
|
||||
chart["created"] = node(m.CreatedAt.UTC().Format(time.RFC3339Nano))
|
||||
entries[m.Name] = append(entries[m.Name], chart)
|
||||
if m.CreatedAt.After(newest) {
|
||||
newest = m.CreatedAt
|
||||
}
|
||||
}
|
||||
return yaml.Marshal(map[string]any{
|
||||
"apiVersion": "v1",
|
||||
"entries": entries,
|
||||
"generated": newest.UTC().Format(time.RFC3339Nano),
|
||||
})
|
||||
}
|
||||
|
||||
func node(v any) yaml.Node {
|
||||
var n yaml.Node
|
||||
_ = n.Encode(v)
|
||||
return n
|
||||
}
|
||||
|
||||
const (
|
||||
maxChartYAML = 1 << 20
|
||||
maxChartBytes = 50 << 20
|
||||
maxChartEntries = 10000
|
||||
)
|
||||
|
||||
var errChartTooLarge = fmt.Errorf("chart archive exceeds %d decompressed bytes", maxChartBytes)
|
||||
|
||||
type capReader struct {
|
||||
r io.Reader
|
||||
n int64
|
||||
}
|
||||
|
||||
func (c *capReader) Read(p []byte) (int, error) {
|
||||
if c.n <= 0 {
|
||||
return 0, errChartTooLarge
|
||||
}
|
||||
if int64(len(p)) > c.n {
|
||||
p = p[:c.n]
|
||||
}
|
||||
n, err := c.r.Read(p)
|
||||
c.n -= int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// parseChart reads the top-level <chart>/Chart.yaml from a packaged chart.
|
||||
func parseChart(r io.Reader) (*provider.HelmMetadata, error) {
|
||||
zr, err := gzip.NewReader(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tr := tar.NewReader(&capReader{r: zr, n: maxChartBytes})
|
||||
for entries := 0; ; entries++ {
|
||||
if entries >= maxChartEntries {
|
||||
return nil, fmt.Errorf("chart archive exceeds %d entries", maxChartEntries)
|
||||
}
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
return nil, errors.New("no Chart.yaml in chart archive")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dir, file := path.Split(path.Clean(hdr.Name))
|
||||
if file != "Chart.yaml" || strings.Count(dir, "/") != 1 {
|
||||
continue
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(tr, maxChartYAML+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(raw) > maxChartYAML {
|
||||
return nil, fmt.Errorf("parse Chart.yaml: exceeds %d bytes", maxChartYAML)
|
||||
}
|
||||
var chart struct {
|
||||
Name string `yaml:"name"`
|
||||
Version string `yaml:"version"`
|
||||
}
|
||||
if err := yaml.Unmarshal(raw, &chart); err != nil {
|
||||
return nil, fmt.Errorf("parse Chart.yaml: %w", err)
|
||||
}
|
||||
if chart.Name == "" || chart.Version == "" {
|
||||
return nil, errors.New("chart metadata missing name or version")
|
||||
}
|
||||
return &provider.HelmMetadata{Name: chart.Name, Version: chart.Version, ChartYAML: string(raw)}, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
package helm
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/provider"
|
||||
)
|
||||
|
||||
func chartTgz(t *testing.T, files map[string]string) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
zw := gzip.NewWriter(&buf)
|
||||
tw := tar.NewWriter(zw)
|
||||
for name, body := range files {
|
||||
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: int64(len(body))}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tw.Write([]byte(body)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
type fakeBlobs struct{ data []byte }
|
||||
|
||||
func (f fakeBlobs) Download(context.Context, string) (io.ReadCloser, int64, error) {
|
||||
return io.NopCloser(bytes.NewReader(f.data)), int64(len(f.data)), nil
|
||||
}
|
||||
|
||||
type fakeStore struct {
|
||||
inserted *provider.HelmMetadata
|
||||
metas []provider.HelmMetadata
|
||||
}
|
||||
|
||||
func (f *fakeStore) InsertRPMMetadata(context.Context, *provider.RPMMetadata) error { return nil }
|
||||
func (f *fakeStore) InsertDebMetadata(context.Context, *provider.DebMetadata) error { return nil }
|
||||
func (f *fakeStore) InsertHelmMetadata(_ context.Context, m *provider.HelmMetadata) error {
|
||||
f.inserted = m
|
||||
return nil
|
||||
}
|
||||
func (f *fakeStore) ListHelmMetadataEntries(context.Context, string) ([]provider.HelmMetadata, error) {
|
||||
return f.metas, nil
|
||||
}
|
||||
func (f *fakeStore) ListFilesByPrefix(context.Context, string, string) ([]provider.FileEntry, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeStore) ListPackages(context.Context, string) ([]string, error) { return nil, nil }
|
||||
|
||||
func TestValidateUpload(t *testing.T) {
|
||||
p := &Provider{}
|
||||
for in, want := range map[string]string{
|
||||
"mychart-0.1.0.tgz": "mychart-0.1.0.tgz",
|
||||
"charts/mychart-0.1.0.tgz": "mychart-0.1.0.tgz",
|
||||
"../../mychart-0.1.0.tgz": "mychart-0.1.0.tgz",
|
||||
} {
|
||||
sp, ct, err := p.ValidateUpload(in)
|
||||
if err != nil || sp != want || ct != "application/gzip" {
|
||||
t.Errorf("ValidateUpload(%q) = %q %q %v", in, sp, ct, err)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"index.yaml", "chart.tar.gz", ".tgz", ""} {
|
||||
if _, _, err := p.ValidateUpload(bad); err == nil {
|
||||
t.Errorf("ValidateUpload(%q) accepted", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAfterUploadParsesTopLevelChart(t *testing.T) {
|
||||
data := chartTgz(t, map[string]string{
|
||||
"mychart/charts/sub/Chart.yaml": "apiVersion: v2\nname: sub\nversion: 9.9.9\n",
|
||||
"mychart/Chart.yaml": "apiVersion: v2\nname: mychart\nversion: 1.0\ndescription: hi\n",
|
||||
"mychart/values.yaml": "a: 1\n",
|
||||
})
|
||||
store := &fakeStore{}
|
||||
(&Provider{}).AfterUpload(context.Background(), "r", "mychart-1.0.tgz", "sha256:ab", fakeBlobs{data}, store)
|
||||
m := store.inserted
|
||||
if m == nil || m.Name != "mychart" || m.Version != "1.0" || m.FilePath != "mychart-1.0.tgz" || m.ContentHash != "sha256:ab" {
|
||||
t.Fatalf("inserted %+v", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAfterUploadRejectsChartWithoutMetadata(t *testing.T) {
|
||||
store := &fakeStore{}
|
||||
data := chartTgz(t, map[string]string{"mychart/Chart.yaml": "apiVersion: v2\nname: mychart\n"})
|
||||
(&Provider{}).AfterUpload(context.Background(), "r", "x.tgz", "sha256:ab", fakeBlobs{data}, store)
|
||||
if store.inserted != nil {
|
||||
t.Fatal("chart without version was recorded")
|
||||
}
|
||||
(&Provider{}).AfterUpload(context.Background(), "r", "x.tgz", "sha256:ab", fakeBlobs{[]byte("not gzip")}, store)
|
||||
if store.inserted != nil {
|
||||
t.Fatal("non-gzip upload was recorded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeLocalIndex(t *testing.T) {
|
||||
t1 := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
t2 := t1.Add(time.Hour)
|
||||
store := &fakeStore{metas: []provider.HelmMetadata{
|
||||
{FilePath: "mychart-1.0.tgz", ContentHash: "sha256:aa", Name: "mychart", Version: "1.0", CreatedAt: t1,
|
||||
ChartYAML: "apiVersion: v2\nname: mychart\nversion: 1.0\nappVersion: \"2\"\ndescription: hi\n"},
|
||||
{FilePath: "other-0.2.0.tgz", ContentHash: "sha256:bb", Name: "other", Version: "0.2.0", CreatedAt: t2,
|
||||
ChartYAML: "apiVersion: v2\nname: other\nversion: 0.2.0\n"},
|
||||
}}
|
||||
p := &Provider{}
|
||||
|
||||
if p.ServeLocalIndex(httptest.NewRecorder(), httptest.NewRequest("GET", "/", nil), store, "r", "mychart-1.0.tgz") {
|
||||
t.Fatal("chart path handled as index")
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
if !p.ServeLocalIndex(rec, httptest.NewRequest("GET", "/", nil), store, "r", "./index.yaml") {
|
||||
t.Fatal("index.yaml not handled")
|
||||
}
|
||||
if rec.Code != 200 || rec.Header().Get("Content-Type") != "text/yaml" {
|
||||
t.Fatalf("code=%d ct=%q", rec.Code, rec.Header().Get("Content-Type"))
|
||||
}
|
||||
|
||||
var idx struct {
|
||||
APIVersion string `yaml:"apiVersion"`
|
||||
Generated string `yaml:"generated"`
|
||||
Entries map[string][]map[string]any
|
||||
}
|
||||
if err := yaml.Unmarshal(rec.Body.Bytes(), &idx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if idx.APIVersion != "v1" || idx.Generated != t2.Format(time.RFC3339Nano) {
|
||||
t.Errorf("header %+v", idx)
|
||||
}
|
||||
e := idx.Entries["mychart"][0]
|
||||
if e["digest"] != "aa" || e["description"] != "hi" || e["appVersion"] != "2" || e["apiVersion"] != "v2" {
|
||||
t.Errorf("entry %v", e)
|
||||
}
|
||||
if urls, _ := e["urls"].([]any); len(urls) != 1 || urls[0] != "mychart-1.0.tgz" {
|
||||
t.Errorf("urls %v", e["urls"])
|
||||
}
|
||||
if e["version"] != "1.0" || e["name"] != "mychart" {
|
||||
t.Errorf("version must stay a string: %#v", e["version"])
|
||||
}
|
||||
if len(idx.Entries["other"]) != 1 {
|
||||
t.Errorf("other entries %v", idx.Entries["other"])
|
||||
}
|
||||
|
||||
again, err := p.GenerateLocalIndex(context.Background(), store, "r", "index.yaml")
|
||||
if err != nil || !bytes.Equal(again, rec.Body.Bytes()) {
|
||||
t.Errorf("index not deterministic: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAfterUploadRejectsFilenameMismatch(t *testing.T) {
|
||||
store := &fakeStore{}
|
||||
data := chartTgz(t, map[string]string{"mychart/Chart.yaml": "apiVersion: v2\nname: mychart\nversion: 1.0.0\n"})
|
||||
(&Provider{}).AfterUpload(context.Background(), "r", "other-1.0.0.tgz", "sha256:ab", fakeBlobs{data}, store)
|
||||
if store.inserted != nil {
|
||||
t.Fatal("chart indexed under a filename that does not match name-version")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseChartLimits(t *testing.T) {
|
||||
chart := "apiVersion: v2\nname: c\nversion: 1.0.0\n"
|
||||
for name, tc := range map[string]struct {
|
||||
files map[string]string
|
||||
want string
|
||||
}{
|
||||
"decompressed": {map[string]string{"c/a.bin": strings.Repeat("\x00", maxChartBytes+1)}, "decompressed bytes"},
|
||||
"chart yaml": {map[string]string{"c/Chart.yaml": chart + "#" + strings.Repeat("x", maxChartYAML)}, "Chart.yaml: exceeds"},
|
||||
} {
|
||||
_, err := parseChart(bytes.NewReader(chartTgz(t, tc.files)))
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Errorf("%s: err = %v, want %q", name, err, tc.want)
|
||||
}
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
zw := gzip.NewWriter(&buf)
|
||||
tw := tar.NewWriter(zw)
|
||||
for i := 0; i <= maxChartEntries; i++ {
|
||||
_ = tw.WriteHeader(&tar.Header{Name: "c/templates/" + strconv.Itoa(i), Mode: 0o644})
|
||||
}
|
||||
_ = tw.WriteHeader(&tar.Header{Name: "c/Chart.yaml", Mode: 0o644, Size: int64(len(chart))})
|
||||
_, _ = tw.Write([]byte(chart))
|
||||
_ = tw.Close()
|
||||
_ = zw.Close()
|
||||
if _, err := parseChart(&buf); err == nil || !strings.Contains(err.Error(), "entries") {
|
||||
t.Errorf("entries: err = %v", err)
|
||||
}
|
||||
|
||||
if m, err := parseChart(bytes.NewReader(chartTgz(t, map[string]string{"c/Chart.yaml": chart}))); err != nil || m.Name != "c" {
|
||||
t.Errorf("valid chart: %v %v", m, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndexEscapesURLs(t *testing.T) {
|
||||
out, err := generateIndex([]provider.HelmMetadata{{FilePath: "my chart-1.0.0.tgz", Name: "my chart", Version: "1.0.0", ChartYAML: "name: x\n"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(out), "my%20chart-1.0.0.tgz") {
|
||||
t.Errorf("url not escaped:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndexExpandsAliasesBeforeOverride(t *testing.T) {
|
||||
body, err := generateIndex([]provider.HelmMetadata{{
|
||||
FilePath: "app-1.0.0.tgz", ContentHash: "sha256:aa", Name: "app", Version: "1.0.0",
|
||||
ChartYAML: "apiVersion: v2\nname: app\nversion: &v 1.0.0\nappVersion: *v\n",
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var idx struct {
|
||||
Entries map[string][]map[string]any
|
||||
}
|
||||
if err := yaml.Unmarshal(body, &idx); err != nil {
|
||||
t.Fatalf("index does not parse: %v\n%s", err, body)
|
||||
}
|
||||
if e := idx.Entries["app"]; len(e) != 1 || e[0]["appVersion"] != "1.0.0" {
|
||||
t.Errorf("entries %v", idx.Entries)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// AlpineMetadataStore / AlpineMetadataDeleter / AlpineMetadataReader are the
|
||||
// Alpine-specific persistence surfaces. They are kept separate from the shared
|
||||
// RPM/Deb metadata interfaces so the apk provider can type-assert the generic
|
||||
// MetadataStore/MetadataDeleter/FileStore it is handed without widening (and
|
||||
// thus perturbing the test doubles of) the rpm and deb providers. *database.DB
|
||||
// satisfies all three.
|
||||
type AlpineMetadataStore interface {
|
||||
InsertAlpineMetadata(ctx context.Context, meta *AlpineMetadata) error
|
||||
}
|
||||
|
||||
type AlpineMetadataDeleter interface {
|
||||
DeleteAlpineMetadata(ctx context.Context, repoName, filePath string) error
|
||||
}
|
||||
|
||||
type AlpineMetadataReader interface {
|
||||
ListAlpineMetadataEntries(ctx context.Context, repoName string) ([]AlpineMetadata, error)
|
||||
}
|
||||
|
||||
// AlpineMetadata is the derived per-package metadata for an Alpine .apk, holding
|
||||
// the fields an APKINDEX record carries plus the apk pull checksum (Q1…, the
|
||||
// sha1 of the control gzip stream) and the download/installed sizes.
|
||||
type AlpineMetadata struct {
|
||||
RepoName string
|
||||
FilePath string
|
||||
ContentHash string
|
||||
Checksum string // C: "Q1" + base64(sha1(control gzip stream))
|
||||
Name string // P:
|
||||
Version string // V:
|
||||
Arch string // A:
|
||||
DownloadSize int64 // S: on-disk .apk size
|
||||
InstalledSize int64 // I: unpacked size from .PKGINFO
|
||||
Description string // T:
|
||||
URL string // U:
|
||||
License string // L:
|
||||
Origin string // o:
|
||||
Maintainer string // m:
|
||||
BuildTime int64 // t:
|
||||
Commit string // c:
|
||||
ProviderPriority string // k:
|
||||
Depends []string // D:
|
||||
Provides []string // p:
|
||||
InstallIf []string // i:
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DebMetadataReader is the read surface the deb LocalIndexer needs to
|
||||
// regenerate a flat apt repository (Packages/Release) from stored rows.
|
||||
// *database.DB satisfies it; ServeLocalIndex type-asserts the FileStore to it,
|
||||
// mirroring how the rpm provider reaches its RPMMetadataReader.
|
||||
type DebMetadataReader interface {
|
||||
ListDebMetadataEntries(ctx context.Context, repoName string) ([]DebMetadata, error)
|
||||
}
|
||||
|
||||
// DebMetadata is the derived per-package metadata for a Debian .deb, carrying
|
||||
// the full raw control stanza so the Packages index can be regenerated
|
||||
// faithfully alongside the computed size/md5/sha256 apt requires.
|
||||
type DebMetadata struct {
|
||||
RepoName string
|
||||
FilePath string
|
||||
ContentHash string
|
||||
Name string
|
||||
Version string
|
||||
Architecture string
|
||||
Control string
|
||||
Size int64
|
||||
MD5 string
|
||||
SHA256 string
|
||||
// CreatedAt is the persisted insert time; the Release Date: is derived from
|
||||
// the newest value so the index is byte-identical across replicas and
|
||||
// regenerations (issue #117) rather than stamped from wall clock.
|
||||
CreatedAt time.Time
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
type HelmMetadataStore interface {
|
||||
InsertHelmMetadata(ctx context.Context, meta *HelmMetadata) error
|
||||
}
|
||||
|
||||
type HelmMetadataDeleter interface {
|
||||
DeleteHelmMetadata(ctx context.Context, repoName, filePath string) error
|
||||
}
|
||||
|
||||
type HelmMetadataReader interface {
|
||||
ListHelmMetadataEntries(ctx context.Context, repoName string) ([]HelmMetadata, error)
|
||||
}
|
||||
|
||||
// HelmMetadata is a local chart's Chart.yaml, kept verbatim so index.yaml
|
||||
// entries carry every field helm reads.
|
||||
type HelmMetadata struct {
|
||||
RepoName string
|
||||
FilePath string
|
||||
ContentHash string
|
||||
Name string
|
||||
Version string
|
||||
ChartYAML string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
type PuppetMetadataStore interface {
|
||||
InsertPuppetMetadata(ctx context.Context, meta *PuppetMetadata) error
|
||||
}
|
||||
|
||||
type PuppetMetadataDeleter interface {
|
||||
DeletePuppetMetadata(ctx context.Context, repoName, filePath string) error
|
||||
}
|
||||
|
||||
type PuppetMetadataReader interface {
|
||||
ListPuppetMetadataEntries(ctx context.Context, repoName, slug string) ([]PuppetMetadata, error)
|
||||
}
|
||||
|
||||
// PuppetMetadata is a local module release; MetadataJSON is the tarball's
|
||||
// metadata.json verbatim so Forge release objects carry every field.
|
||||
type PuppetMetadata struct {
|
||||
RepoName string
|
||||
FilePath string
|
||||
ContentHash string
|
||||
Slug string
|
||||
Version string
|
||||
MetadataJSON string
|
||||
Size int64
|
||||
MD5 string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
type RPMMetadataReader interface {
|
||||
ListRPMMetadataEntries(ctx context.Context, repoName string) ([]RPMMetadata, error)
|
||||
}
|
||||
|
||||
type RPMMetadata struct {
|
||||
RepoName string
|
||||
FilePath string
|
||||
ContentHash string
|
||||
Name string
|
||||
Epoch int
|
||||
Version string
|
||||
Release string
|
||||
Arch string
|
||||
Summary string
|
||||
Description string
|
||||
RPMSize int64
|
||||
InstalledSize int64
|
||||
License string
|
||||
Vendor string
|
||||
Group string
|
||||
BuildHost string
|
||||
SourceRPM string
|
||||
URL string
|
||||
Packager string
|
||||
Requires []RPMDep
|
||||
Provides []RPMDep
|
||||
Conflicts []RPMDep
|
||||
Obsoletes []RPMDep
|
||||
Files []RPMFile
|
||||
Changelogs []RPMChangelog
|
||||
// CreatedAt is the persisted upload timestamp; used as a stable, replica-independent
|
||||
// value for the repodata <time>/<revision> fields so generated indexes are deterministic.
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type RPMDep struct {
|
||||
Name string `json:"name"`
|
||||
Flags string `json:"flags,omitempty"`
|
||||
Epoch string `json:"epoch,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Release string `json:"release,omitempty"`
|
||||
}
|
||||
|
||||
type RPMFile struct {
|
||||
Path string `json:"path"`
|
||||
Type string `json:"type,omitempty"`
|
||||
}
|
||||
|
||||
type RPMChangelog struct {
|
||||
Author string `json:"author"`
|
||||
Date int64 `json:"date"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
type MetadataStore interface {
|
||||
InsertRPMMetadata(ctx context.Context, meta *RPMMetadata) error
|
||||
InsertDebMetadata(ctx context.Context, meta *DebMetadata) error
|
||||
}
|
||||
|
||||
type MetadataDeleter interface {
|
||||
DeleteRPMMetadata(ctx context.Context, repoName, filePath string) error
|
||||
DeleteDebMetadata(ctx context.Context, repoName, filePath string) error
|
||||
}
|
||||
@@ -3,9 +3,7 @@ package provider
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/pkg/models"
|
||||
)
|
||||
@@ -31,205 +29,6 @@ type FileEntry struct {
|
||||
ContentHash string
|
||||
}
|
||||
|
||||
type FileStore interface {
|
||||
ListFilesByPrefix(ctx context.Context, repoName, prefix string) ([]FileEntry, error)
|
||||
ListPackages(ctx context.Context, repoName string) ([]string, error)
|
||||
}
|
||||
|
||||
type LocalUploader interface {
|
||||
ValidateUpload(filePath string) (storagePath, contentType string, err error)
|
||||
UploadResponse(storagePath, contentHash string, sizeBytes int64) map[string]any
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
type BlobReader interface {
|
||||
Download(ctx context.Context, key string) (io.ReadCloser, int64, error)
|
||||
}
|
||||
|
||||
type PostUploadHook interface {
|
||||
AfterUpload(ctx context.Context, repoName, storagePath, contentHash string, blobs BlobReader, db MetadataStore)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
type MetadataStore interface {
|
||||
InsertRPMMetadata(ctx context.Context, meta *RPMMetadata) error
|
||||
InsertDebMetadata(ctx context.Context, meta *DebMetadata) error
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
type MetadataDeleter interface {
|
||||
DeleteRPMMetadata(ctx context.Context, repoName, filePath string) error
|
||||
DeleteDebMetadata(ctx context.Context, repoName, filePath string) error
|
||||
}
|
||||
|
||||
type RPMMetadataReader interface {
|
||||
ListRPMMetadataEntries(ctx context.Context, repoName string) ([]RPMMetadata, error)
|
||||
}
|
||||
|
||||
// DebMetadataReader is the read surface the deb LocalIndexer needs to
|
||||
// regenerate a flat apt repository (Packages/Release) from stored rows.
|
||||
// *database.DB satisfies it; ServeLocalIndex type-asserts the FileStore to it,
|
||||
// mirroring how the rpm provider reaches its RPMMetadataReader.
|
||||
type DebMetadataReader interface {
|
||||
ListDebMetadataEntries(ctx context.Context, repoName string) ([]DebMetadata, error)
|
||||
}
|
||||
|
||||
// DebMetadata is the derived per-package metadata for a Debian .deb, carrying
|
||||
// the full raw control stanza so the Packages index can be regenerated
|
||||
// faithfully alongside the computed size/md5/sha256 apt requires.
|
||||
type DebMetadata struct {
|
||||
RepoName string
|
||||
FilePath string
|
||||
ContentHash string
|
||||
Name string
|
||||
Version string
|
||||
Architecture string
|
||||
Control string
|
||||
Size int64
|
||||
MD5 string
|
||||
SHA256 string
|
||||
// CreatedAt is the persisted insert time; the Release Date: is derived from
|
||||
// the newest value so the index is byte-identical across replicas and
|
||||
// regenerations (issue #117) rather than stamped from wall clock.
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// AlpineMetadataStore / AlpineMetadataDeleter / AlpineMetadataReader are the
|
||||
// Alpine-specific persistence surfaces. They are kept separate from the shared
|
||||
// RPM/Deb metadata interfaces so the apk provider can type-assert the generic
|
||||
// MetadataStore/MetadataDeleter/FileStore it is handed without widening (and
|
||||
// thus perturbing the test doubles of) the rpm and deb providers. *database.DB
|
||||
// satisfies all three.
|
||||
type AlpineMetadataStore interface {
|
||||
InsertAlpineMetadata(ctx context.Context, meta *AlpineMetadata) error
|
||||
}
|
||||
|
||||
type AlpineMetadataDeleter interface {
|
||||
DeleteAlpineMetadata(ctx context.Context, repoName, filePath string) error
|
||||
}
|
||||
|
||||
type AlpineMetadataReader interface {
|
||||
ListAlpineMetadataEntries(ctx context.Context, repoName string) ([]AlpineMetadata, error)
|
||||
}
|
||||
|
||||
// AlpineMetadata is the derived per-package metadata for an Alpine .apk, holding
|
||||
// the fields an APKINDEX record carries plus the apk pull checksum (Q1…, the
|
||||
// sha1 of the control gzip stream) and the download/installed sizes.
|
||||
type AlpineMetadata struct {
|
||||
RepoName string
|
||||
FilePath string
|
||||
ContentHash string
|
||||
Checksum string // C: "Q1" + base64(sha1(control gzip stream))
|
||||
Name string // P:
|
||||
Version string // V:
|
||||
Arch string // A:
|
||||
DownloadSize int64 // S: on-disk .apk size
|
||||
InstalledSize int64 // I: unpacked size from .PKGINFO
|
||||
Description string // T:
|
||||
URL string // U:
|
||||
License string // L:
|
||||
Origin string // o:
|
||||
Maintainer string // m:
|
||||
BuildTime int64 // t:
|
||||
Commit string // c:
|
||||
ProviderPriority string // k:
|
||||
Depends []string // D:
|
||||
Provides []string // p:
|
||||
InstallIf []string // i:
|
||||
}
|
||||
|
||||
type RPMMetadata struct {
|
||||
RepoName string
|
||||
FilePath string
|
||||
ContentHash string
|
||||
Name string
|
||||
Epoch int
|
||||
Version string
|
||||
Release string
|
||||
Arch string
|
||||
Summary string
|
||||
Description string
|
||||
RPMSize int64
|
||||
InstalledSize int64
|
||||
License string
|
||||
Vendor string
|
||||
Group string
|
||||
BuildHost string
|
||||
SourceRPM string
|
||||
URL string
|
||||
Packager string
|
||||
Requires []RPMDep
|
||||
Provides []RPMDep
|
||||
Conflicts []RPMDep
|
||||
Obsoletes []RPMDep
|
||||
Files []RPMFile
|
||||
Changelogs []RPMChangelog
|
||||
// CreatedAt is the persisted upload timestamp; used as a stable, replica-independent
|
||||
// value for the repodata <time>/<revision> fields so generated indexes are deterministic.
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type RPMDep struct {
|
||||
Name string `json:"name"`
|
||||
Flags string `json:"flags,omitempty"`
|
||||
Epoch string `json:"epoch,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Release string `json:"release,omitempty"`
|
||||
}
|
||||
|
||||
type RPMFile struct {
|
||||
Path string `json:"path"`
|
||||
Type string `json:"type,omitempty"`
|
||||
}
|
||||
|
||||
type RPMChangelog struct {
|
||||
Author string `json:"author"`
|
||||
Date int64 `json:"date"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
type IndexMerger interface {
|
||||
MergeIndexes(members []MemberIndex, proxyBaseURL string) ([]byte, error)
|
||||
}
|
||||
|
||||
type MemberIndex struct {
|
||||
RemoteName string
|
||||
Body []byte
|
||||
}
|
||||
|
||||
var registry = map[models.PackageType]Provider{}
|
||||
|
||||
func Register(p Provider) {
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
package puppet
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/provider"
|
||||
"git.unkin.net/unkin/artifactapi/internal/storage"
|
||||
)
|
||||
|
||||
const filesPrefix = "v3/files/"
|
||||
|
||||
// releaseRe matches <owner>-<name>-<semver>, the Forge release slug and the
|
||||
// tarball basename that `puppet module build` produces.
|
||||
var releaseRe = regexp.MustCompile(`^([a-z0-9]+)-([a-z][a-z0-9_]*)-(\d+\.\d+\.\d+(?:-[0-9a-z.-]+)?(?:\+[0-9a-z.-]+)?)$`)
|
||||
|
||||
func parseRelease(s string) (slug, version string, ok bool) {
|
||||
m := releaseRe.FindStringSubmatch(strings.ToLower(s))
|
||||
if m == nil {
|
||||
return "", "", false
|
||||
}
|
||||
return m[1] + "-" + m[2], m[3], true
|
||||
}
|
||||
|
||||
func normalizeSlug(s string) string {
|
||||
return strings.ToLower(strings.ReplaceAll(s, "/", "-"))
|
||||
}
|
||||
|
||||
// ValidateUpload stores tarballs under v3/files/ so Forge file_uri downloads
|
||||
// resolve straight to the stored file.
|
||||
func (p *Provider) ValidateUpload(filePath string) (storagePath, contentType string, err error) {
|
||||
filename := strings.ToLower(path.Base(path.Clean("/" + filePath)))
|
||||
if _, _, ok := parseRelease(strings.TrimSuffix(filename, ".tar.gz")); !ok || !strings.HasSuffix(filename, ".tar.gz") {
|
||||
return "", "", fmt.Errorf("file must be named <owner>-<module>-<version>.tar.gz")
|
||||
}
|
||||
return filesPrefix + filename, "application/gzip", nil
|
||||
}
|
||||
|
||||
func (p *Provider) UploadResponse(storagePath, contentHash string, sizeBytes int64) map[string]any {
|
||||
return map[string]any{
|
||||
"filename": path.Base(storagePath),
|
||||
"content_hash": contentHash,
|
||||
"size_bytes": sizeBytes,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Provider) AfterUpload(ctx context.Context, repoName, storagePath, contentHash string, blobs provider.BlobReader, db provider.MetadataStore) {
|
||||
reader, size, err := blobs.Download(ctx, storage.BlobKey(strings.TrimPrefix(contentHash, "sha256:")))
|
||||
if err != nil {
|
||||
slog.Error("puppet metadata: download failed", "repo", repoName, "path", storagePath, "error", err)
|
||||
return
|
||||
}
|
||||
defer func() { _ = reader.Close() }()
|
||||
if size > maxModuleTarball {
|
||||
slog.Error("puppet metadata: tarball too large", "repo", repoName, "path", storagePath, "size", size)
|
||||
return
|
||||
}
|
||||
|
||||
hash := md5.New()
|
||||
src := io.TeeReader(io.LimitReader(reader, maxModuleTarball), hash)
|
||||
meta, err := parseModule(src)
|
||||
if err != nil {
|
||||
slog.Error("puppet metadata: parse failed", "repo", repoName, "path", storagePath, "error", err)
|
||||
return
|
||||
}
|
||||
if _, err := io.Copy(io.Discard, src); err != nil {
|
||||
slog.Error("puppet metadata: read failed", "repo", repoName, "path", storagePath, "error", err)
|
||||
return
|
||||
}
|
||||
slug, version, _ := parseRelease(strings.TrimSuffix(path.Base(storagePath), ".tar.gz"))
|
||||
if meta.Slug != slug || meta.Version != version {
|
||||
slog.Error("puppet metadata: metadata.json does not match filename", "repo", repoName, "path", storagePath, "name", meta.Slug, "version", meta.Version)
|
||||
return
|
||||
}
|
||||
meta.RepoName = repoName
|
||||
meta.FilePath = storagePath
|
||||
meta.ContentHash = contentHash
|
||||
meta.Size = size
|
||||
meta.MD5 = hex.EncodeToString(hash.Sum(nil))
|
||||
|
||||
store, ok := db.(provider.PuppetMetadataStore)
|
||||
if !ok {
|
||||
slog.Error("puppet metadata: store does not support puppet metadata", "repo", repoName)
|
||||
return
|
||||
}
|
||||
if err := store.InsertPuppetMetadata(ctx, meta); err != nil {
|
||||
slog.Error("puppet metadata: insert failed", "repo", repoName, "path", storagePath, "error", err)
|
||||
return
|
||||
}
|
||||
slog.Info("puppet metadata: parsed", "repo", repoName, "module", meta.Slug, "version", meta.Version)
|
||||
}
|
||||
|
||||
func (p *Provider) AfterDelete(ctx context.Context, repoName, storagePath string, db provider.MetadataDeleter) error {
|
||||
deleter, ok := db.(provider.PuppetMetadataDeleter)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return deleter.DeletePuppetMetadata(ctx, repoName, storagePath)
|
||||
}
|
||||
|
||||
const maxMetadataJSON = 1 << 20
|
||||
|
||||
var (
|
||||
maxModuleTarball int64 = 100 << 20
|
||||
maxModuleScan int64 = 200 << 20
|
||||
)
|
||||
|
||||
// parseModule reads the top-level <dir>/metadata.json from a module tarball,
|
||||
// scanning at most maxModuleScan decompressed bytes.
|
||||
func parseModule(r io.Reader) (*provider.PuppetMetadata, error) {
|
||||
zr, err := gzip.NewReader(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scan := &io.LimitedReader{R: zr, N: maxModuleScan}
|
||||
tr := tar.NewReader(scan)
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if scan.N <= 0 {
|
||||
return nil, errors.New("module tarball exceeds decompressed scan limit before metadata.json")
|
||||
}
|
||||
if err == io.EOF {
|
||||
return nil, errors.New("no metadata.json in module tarball")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dir, file := path.Split(strings.TrimPrefix(path.Clean(hdr.Name), "./"))
|
||||
if hdr.Typeflag != tar.TypeReg || file != "metadata.json" || strings.Count(dir, "/") != 1 {
|
||||
continue
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(tr, maxMetadataJSON))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var md struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &md); err != nil {
|
||||
return nil, fmt.Errorf("parse metadata.json: %w", err)
|
||||
}
|
||||
if md.Name == "" || md.Version == "" {
|
||||
return nil, errors.New("metadata.json missing name or version")
|
||||
}
|
||||
return &provider.PuppetMetadata{Slug: normalizeSlug(md.Name), Version: md.Version, MetadataJSON: string(body)}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Provider) ServeLocalIndex(w http.ResponseWriter, r *http.Request, files provider.FileStore, repoName, reqPath string) bool {
|
||||
clean := strings.TrimPrefix(path.Clean("/"+reqPath), "/")
|
||||
var list func(slug string) ([]provider.PuppetMetadata, error)
|
||||
if reader, ok := files.(provider.PuppetMetadataReader); ok {
|
||||
list = func(slug string) ([]provider.PuppetMetadata, error) {
|
||||
metas, err := reader.ListPuppetMetadataEntries(r.Context(), repoName, slug)
|
||||
sortReleases(metas)
|
||||
return metas, err
|
||||
}
|
||||
} else {
|
||||
list = func(string) ([]provider.PuppetMetadata, error) {
|
||||
return nil, errors.New("puppet metadata not available")
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case clean == "v3/releases":
|
||||
serveReleaseList(w, r, list)
|
||||
case strings.HasPrefix(clean, "v3/releases/"):
|
||||
slug, version, ok := parseRelease(strings.TrimPrefix(clean, "v3/releases/"))
|
||||
if !ok {
|
||||
notFound(w)
|
||||
return true
|
||||
}
|
||||
metas, err := list(slug)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return true
|
||||
}
|
||||
for _, m := range metas {
|
||||
if m.Version == version {
|
||||
writeJSON(w, releaseObject(m))
|
||||
return true
|
||||
}
|
||||
}
|
||||
notFound(w)
|
||||
case strings.HasPrefix(clean, "v3/modules/"):
|
||||
slug := normalizeSlug(strings.TrimPrefix(clean, "v3/modules/"))
|
||||
metas, err := list(slug)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return true
|
||||
}
|
||||
if len(metas) == 0 {
|
||||
notFound(w)
|
||||
return true
|
||||
}
|
||||
writeJSON(w, moduleObject(metas))
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (p *Provider) GenerateLocalIndex(ctx context.Context, files provider.FileStore, repoName, path string) ([]byte, error) {
|
||||
return nil, fmt.Errorf("puppet local index generation for virtual repos not supported")
|
||||
}
|
||||
|
||||
func serveReleaseList(w http.ResponseWriter, r *http.Request, list func(string) ([]provider.PuppetMetadata, error)) {
|
||||
q := r.URL.Query()
|
||||
slug := normalizeSlug(q.Get("module"))
|
||||
limit, err := strconv.Atoi(q.Get("limit"))
|
||||
if err != nil || limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
limit = min(limit, 100)
|
||||
offset, err := strconv.Atoi(q.Get("offset"))
|
||||
if err != nil || offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
metas, err := list(slug)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
offset = min(offset, len(metas))
|
||||
results := []map[string]any{}
|
||||
for _, m := range metas[offset : offset+min(limit, len(metas)-offset)] {
|
||||
results = append(results, releaseObject(m))
|
||||
}
|
||||
|
||||
page := func(off int) any {
|
||||
if off < 0 || off >= len(metas) && off != 0 {
|
||||
return nil
|
||||
}
|
||||
v := url.Values{"limit": {strconv.Itoa(limit)}, "offset": {strconv.Itoa(off)}}
|
||||
if slug != "" {
|
||||
v.Set("module", slug)
|
||||
}
|
||||
return "/v3/releases?" + v.Encode()
|
||||
}
|
||||
var previous any
|
||||
if offset > 0 {
|
||||
previous = page(max(offset-limit, 0))
|
||||
}
|
||||
writeJSON(w, map[string]any{
|
||||
"pagination": map[string]any{
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"first": page(0),
|
||||
"previous": previous,
|
||||
"current": page(offset),
|
||||
"next": page(offset + limit),
|
||||
"total": len(metas),
|
||||
},
|
||||
"results": results,
|
||||
})
|
||||
}
|
||||
|
||||
func owner(slug string) map[string]any {
|
||||
name, _, _ := strings.Cut(slug, "-")
|
||||
return map[string]any{"uri": "/v3/users/" + name, "slug": name, "username": name}
|
||||
}
|
||||
|
||||
func moduleRef(slug string) map[string]any {
|
||||
_, name, _ := strings.Cut(slug, "-")
|
||||
return map[string]any{"uri": "/v3/modules/" + slug, "slug": slug, "name": name, "deprecated_at": nil, "owner": owner(slug)}
|
||||
}
|
||||
|
||||
const forgeTime = "2006-01-02 15:04:05 -0700"
|
||||
|
||||
func releaseObject(m provider.PuppetMetadata) map[string]any {
|
||||
slug := m.Slug + "-" + m.Version
|
||||
return map[string]any{
|
||||
"uri": "/v3/releases/" + slug,
|
||||
"slug": slug,
|
||||
"module": moduleRef(m.Slug),
|
||||
"version": m.Version,
|
||||
"metadata": json.RawMessage(m.MetadataJSON),
|
||||
"tags": []string{},
|
||||
"supported": false,
|
||||
"pdk": false,
|
||||
"file_uri": "/" + m.FilePath,
|
||||
"file_size": m.Size,
|
||||
"file_md5": m.MD5,
|
||||
"file_sha256": strings.TrimPrefix(m.ContentHash, "sha256:"),
|
||||
"downloads": 0,
|
||||
"readme": nil,
|
||||
"changelog": nil,
|
||||
"license": nil,
|
||||
"created_at": m.CreatedAt.Format(forgeTime),
|
||||
"updated_at": m.CreatedAt.Format(forgeTime),
|
||||
"deleted_at": nil,
|
||||
}
|
||||
}
|
||||
|
||||
// moduleObject expects metas sorted newest version first.
|
||||
func moduleObject(metas []provider.PuppetMetadata) map[string]any {
|
||||
obj := moduleRef(metas[0].Slug)
|
||||
releases := make([]map[string]any, 0, len(metas))
|
||||
for _, m := range metas {
|
||||
slug := m.Slug + "-" + m.Version
|
||||
releases = append(releases, map[string]any{
|
||||
"uri": "/v3/releases/" + slug, "slug": slug, "version": m.Version,
|
||||
"file_uri": "/" + m.FilePath, "file_size": m.Size, "supported": false,
|
||||
"created_at": m.CreatedAt.Format(forgeTime), "deleted_at": nil,
|
||||
})
|
||||
}
|
||||
obj["current_release"] = releaseObject(metas[0])
|
||||
obj["releases"] = releases
|
||||
obj["downloads"] = 0
|
||||
obj["created_at"] = metas[len(metas)-1].CreatedAt.Format(forgeTime)
|
||||
obj["updated_at"] = metas[0].CreatedAt.Format(forgeTime)
|
||||
return obj
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetEscapeHTML(false)
|
||||
_ = enc.Encode(v)
|
||||
}
|
||||
|
||||
func notFound(w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = io.WriteString(w, `{"message":"404 Not Found","errors":["The requested resource could not be found"]}`)
|
||||
}
|
||||
|
||||
// sortReleases orders by module slug, then newest semver first.
|
||||
func sortReleases(metas []provider.PuppetMetadata) {
|
||||
slices.SortStableFunc(metas, func(a, b provider.PuppetMetadata) int {
|
||||
if c := strings.Compare(a.Slug, b.Slug); c != 0 {
|
||||
return c
|
||||
}
|
||||
return compareSemver(b.Version, a.Version)
|
||||
})
|
||||
}
|
||||
|
||||
func compareSemver(a, b string) int {
|
||||
a, _, _ = strings.Cut(a, "+")
|
||||
b, _, _ = strings.Cut(b, "+")
|
||||
aCore, aPre, aHasPre := strings.Cut(a, "-")
|
||||
bCore, bPre, bHasPre := strings.Cut(b, "-")
|
||||
if c := compareIdents(strings.Split(aCore, "."), strings.Split(bCore, ".")); c != 0 {
|
||||
return c
|
||||
}
|
||||
switch {
|
||||
case aHasPre && !bHasPre:
|
||||
return -1
|
||||
case !aHasPre && bHasPre:
|
||||
return 1
|
||||
}
|
||||
return compareIdents(strings.Split(aPre, "."), strings.Split(bPre, "."))
|
||||
}
|
||||
|
||||
func compareIdents(a, b []string) int {
|
||||
for i := 0; i < len(a) && i < len(b); i++ {
|
||||
an, aErr := strconv.Atoi(a[i])
|
||||
bn, bErr := strconv.Atoi(b[i])
|
||||
var c int
|
||||
switch {
|
||||
case aErr == nil && bErr == nil:
|
||||
c = an - bn
|
||||
case aErr == nil:
|
||||
c = -1
|
||||
case bErr == nil:
|
||||
c = 1
|
||||
default:
|
||||
c = strings.Compare(a[i], b[i])
|
||||
}
|
||||
if c != 0 {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return len(a) - len(b)
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
package puppet
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/provider"
|
||||
)
|
||||
|
||||
func moduleTarball(t *testing.T, dir, metadata string) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
zw := gzip.NewWriter(&buf)
|
||||
tw := tar.NewWriter(zw)
|
||||
for name, body := range map[string]string{dir + "/metadata.json": metadata, dir + "/manifests/init.pp": "class hello {}\n"} {
|
||||
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: int64(len(body))}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = tw.Write([]byte(body))
|
||||
}
|
||||
_ = tw.Close()
|
||||
_ = zw.Close()
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
const helloMetadata = `{"name":"unkin-hello","version":"0.1.0","dependencies":[{"name":"puppetlabs/stdlib","version_requirement":">= 4.0.0"}]}`
|
||||
|
||||
type fakeBlobs struct{ data []byte }
|
||||
|
||||
func (f fakeBlobs) Download(context.Context, string) (io.ReadCloser, int64, error) {
|
||||
return io.NopCloser(bytes.NewReader(f.data)), int64(len(f.data)), nil
|
||||
}
|
||||
|
||||
type fakeStore struct{ inserted *provider.PuppetMetadata }
|
||||
|
||||
func (f *fakeStore) InsertRPMMetadata(context.Context, *provider.RPMMetadata) error { return nil }
|
||||
func (f *fakeStore) InsertDebMetadata(context.Context, *provider.DebMetadata) error { return nil }
|
||||
func (f *fakeStore) InsertPuppetMetadata(_ context.Context, m *provider.PuppetMetadata) error {
|
||||
f.inserted = m
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeReader struct{ metas []provider.PuppetMetadata }
|
||||
|
||||
func (f fakeReader) ListPuppetMetadataEntries(_ context.Context, _, slug string) ([]provider.PuppetMetadata, error) {
|
||||
var out []provider.PuppetMetadata
|
||||
for _, m := range f.metas {
|
||||
if slug == "" || m.Slug == slug {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (fakeReader) ListFilesByPrefix(context.Context, string, string) ([]provider.FileEntry, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (fakeReader) ListPackages(context.Context, string) ([]string, error) { return nil, nil }
|
||||
|
||||
func TestValidateUpload(t *testing.T) {
|
||||
p := &Provider{}
|
||||
sp, ct, err := p.ValidateUpload("some/dir/Unkin-hello-0.1.0.tar.gz")
|
||||
if err != nil || sp != "v3/files/unkin-hello-0.1.0.tar.gz" || ct != "application/gzip" {
|
||||
t.Errorf("sp=%q ct=%q err=%v", sp, ct, err)
|
||||
}
|
||||
for _, bad := range []string{"hello-0.1.0.tar.gz", "unkin-hello-0.1.tar.gz", "unkin-hello-0.1.0.zip", "unkin-Hello_x-0.1.0.tgz"} {
|
||||
if _, _, err := p.ValidateUpload(bad); err == nil {
|
||||
t.Errorf("%q accepted", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAfterUploadRecordsMetadata(t *testing.T) {
|
||||
raw := moduleTarball(t, "unkin-hello-0.1.0", helloMetadata)
|
||||
store := &fakeStore{}
|
||||
(&Provider{}).AfterUpload(context.Background(), "forge", "v3/files/unkin-hello-0.1.0.tar.gz", "sha256:abc", fakeBlobs{raw}, store)
|
||||
m := store.inserted
|
||||
if m == nil {
|
||||
t.Fatal("nothing inserted")
|
||||
}
|
||||
sum := md5.Sum(raw)
|
||||
if m.Slug != "unkin-hello" || m.Version != "0.1.0" || m.MD5 != hex.EncodeToString(sum[:]) || m.Size != int64(len(raw)) || m.MetadataJSON != helloMetadata {
|
||||
t.Errorf("got %+v", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAfterUploadRejectsMismatchedFilename(t *testing.T) {
|
||||
raw := moduleTarball(t, "unkin-hello-0.2.0", `{"name":"unkin-hello","version":"0.2.0"}`)
|
||||
store := &fakeStore{}
|
||||
(&Provider{}).AfterUpload(context.Background(), "forge", "v3/files/unkin-hello-0.1.0.tar.gz", "sha256:abc", fakeBlobs{raw}, store)
|
||||
if store.inserted != nil {
|
||||
t.Error("mismatched version indexed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseModuleRequiresTopLevelMetadata(t *testing.T) {
|
||||
if _, err := parseModule(bytes.NewReader(moduleTarball(t, "a/b", helloMetadata))); err == nil {
|
||||
t.Error("nested metadata.json accepted")
|
||||
}
|
||||
if _, err := parseModule(bytes.NewReader(moduleTarball(t, "x", `{"name":"unkin-hello"}`))); err == nil {
|
||||
t.Error("missing version accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func tarballOf(t *testing.T, entries ...*tar.Header) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
zw := gzip.NewWriter(&buf)
|
||||
tw := tar.NewWriter(zw)
|
||||
for _, h := range entries {
|
||||
if err := tw.WriteHeader(h); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if h.Typeflag == tar.TypeReg {
|
||||
body := helloMetadata
|
||||
if !strings.HasSuffix(h.Name, "metadata.json") {
|
||||
body = strings.Repeat("x", int(h.Size))
|
||||
}
|
||||
_, _ = tw.Write([]byte(body))
|
||||
}
|
||||
}
|
||||
_ = tw.Close()
|
||||
_ = zw.Close()
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func TestParseModuleSkipsNonRegularMetadata(t *testing.T) {
|
||||
raw := tarballOf(t,
|
||||
&tar.Header{Name: "unkin-hello-0.1.0/metadata.json", Typeflag: tar.TypeSymlink, Linkname: "/etc/passwd"},
|
||||
&tar.Header{Name: "unkin-hello-0.1.0/metadata.json/", Typeflag: tar.TypeDir, Mode: 0o755},
|
||||
&tar.Header{Name: "unkin-hello-0.1.0/metadata.json", Typeflag: tar.TypeReg, Mode: 0o644, Size: int64(len(helloMetadata))},
|
||||
)
|
||||
meta, err := parseModule(bytes.NewReader(raw))
|
||||
if err != nil || meta.Slug != "unkin-hello" || meta.Version != "0.1.0" {
|
||||
t.Fatalf("got %v, %v", meta, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseModuleCapsDecompressedScan(t *testing.T) {
|
||||
defer func(n int64) { maxModuleScan = n }(maxModuleScan)
|
||||
maxModuleScan = 64 << 10
|
||||
raw := tarballOf(t,
|
||||
&tar.Header{Name: "unkin-hello-0.1.0/big", Typeflag: tar.TypeReg, Mode: 0o644, Size: 1 << 20},
|
||||
&tar.Header{Name: "unkin-hello-0.1.0/metadata.json", Typeflag: tar.TypeReg, Mode: 0o644, Size: int64(len(helloMetadata))},
|
||||
)
|
||||
if _, err := parseModule(bytes.NewReader(raw)); err == nil || !strings.Contains(err.Error(), "scan limit") {
|
||||
t.Fatalf("expected scan limit error, got %v", err)
|
||||
}
|
||||
maxModuleScan = 2 << 20
|
||||
if _, err := parseModule(bytes.NewReader(raw)); err != nil {
|
||||
t.Fatalf("under cap: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAfterUploadCapsTarballSize(t *testing.T) {
|
||||
defer func(n int64) { maxModuleTarball = n }(maxModuleTarball)
|
||||
raw := moduleTarball(t, "unkin-hello-0.1.0", helloMetadata)
|
||||
maxModuleTarball = int64(len(raw)) - 1
|
||||
store := &fakeStore{}
|
||||
(&Provider{}).AfterUpload(context.Background(), "forge", "v3/files/unkin-hello-0.1.0.tar.gz", "sha256:abc", fakeBlobs{raw}, store)
|
||||
if store.inserted != nil {
|
||||
t.Error("oversized tarball indexed")
|
||||
}
|
||||
maxModuleTarball = int64(len(raw))
|
||||
(&Provider{}).AfterUpload(context.Background(), "forge", "v3/files/unkin-hello-0.1.0.tar.gz", "sha256:abc", fakeBlobs{raw}, store)
|
||||
if store.inserted == nil {
|
||||
t.Error("tarball at cap not indexed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseListClampsPagination(t *testing.T) {
|
||||
files := releases("0.1.0", "0.2.0", "0.3.0")
|
||||
for _, q := range []string{"offset=9223372036854775807", "offset=9223372036854775807&limit=9223372036854775807", "offset=-5&limit=-5", "limit=9223372036854775807"} {
|
||||
code, body := get(t, files, "v3/releases?module=unkin-hello&"+q)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("%s: code %d", q, code)
|
||||
}
|
||||
pg := body["pagination"].(map[string]any)
|
||||
if l := pg["limit"].(float64); l < 1 || l > 100 {
|
||||
t.Errorf("%s: limit %v", q, l)
|
||||
}
|
||||
if o := pg["offset"].(float64); o < 0 || o > 3 {
|
||||
t.Errorf("%s: offset %v", q, o)
|
||||
}
|
||||
}
|
||||
_, body := get(t, files, "v3/releases?module=unkin-hello&offset=9223372036854775807")
|
||||
if len(body["results"].([]any)) != 0 || body["pagination"].(map[string]any)["next"] != nil {
|
||||
t.Errorf("past-end page: %v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func get(t *testing.T, files provider.FileStore, target string) (int, map[string]any) {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/x/"+target, nil)
|
||||
path, _, _ := strings.Cut(target, "?")
|
||||
if !(&Provider{}).ServeLocalIndex(rec, req, files, "forge", path) {
|
||||
return 0, nil
|
||||
}
|
||||
var body map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &body)
|
||||
return rec.Code, body
|
||||
}
|
||||
|
||||
func releases(versions ...string) fakeReader {
|
||||
var f fakeReader
|
||||
for i, v := range versions {
|
||||
f.metas = append(f.metas, provider.PuppetMetadata{
|
||||
FilePath: "v3/files/unkin-hello-" + v + ".tar.gz", ContentHash: "sha256:feed", Slug: "unkin-hello",
|
||||
Version: v, MetadataJSON: `{"name":"unkin-hello","version":"` + v + `"}`, MD5: "m" + v,
|
||||
CreatedAt: time.Unix(int64(i), 0),
|
||||
})
|
||||
}
|
||||
f.metas = append(f.metas, provider.PuppetMetadata{Slug: "other-mod", Version: "9.9.9", MetadataJSON: `{}`})
|
||||
return f
|
||||
}
|
||||
|
||||
func TestReleaseListPaginatesNewestFirst(t *testing.T) {
|
||||
files := releases("0.1.0", "0.10.0", "0.2.0", "1.0.0-rc.1", "1.0.0")
|
||||
code, body := get(t, files, "v3/releases?module=unkin/hello&limit=2")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("code %d", code)
|
||||
}
|
||||
results := body["results"].([]any)
|
||||
pg := body["pagination"].(map[string]any)
|
||||
if len(results) != 2 || results[0].(map[string]any)["version"] != "1.0.0" || results[1].(map[string]any)["version"] != "1.0.0-rc.1" {
|
||||
t.Fatalf("page 1: %v", results)
|
||||
}
|
||||
if pg["total"] != float64(5) || pg["next"] != "/v3/releases?limit=2&module=unkin-hello&offset=2" || pg["previous"] != nil {
|
||||
t.Errorf("pagination: %v", pg)
|
||||
}
|
||||
|
||||
_, body = get(t, files, "v3/releases?module=unkin-hello&limit=2&offset=4")
|
||||
results = body["results"].([]any)
|
||||
pg = body["pagination"].(map[string]any)
|
||||
if len(results) != 1 || results[0].(map[string]any)["version"] != "0.1.0" || pg["next"] != nil || pg["previous"] != "/v3/releases?limit=2&module=unkin-hello&offset=2" {
|
||||
t.Errorf("last page: %v %v", results, pg)
|
||||
}
|
||||
|
||||
_, body = get(t, files, "v3/releases?module=nobody-none")
|
||||
if len(body["results"].([]any)) != 0 {
|
||||
t.Error("unknown module returned results")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseObject(t *testing.T) {
|
||||
code, body := get(t, releases("0.1.0"), "v3/releases/unkin-hello-0.1.0")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("code %d", code)
|
||||
}
|
||||
if body["file_uri"] != "/v3/files/unkin-hello-0.1.0.tar.gz" || body["file_md5"] != "m0.1.0" || body["file_sha256"] != "feed" {
|
||||
t.Errorf("release: %v", body)
|
||||
}
|
||||
if body["metadata"].(map[string]any)["name"] != "unkin-hello" || body["module"].(map[string]any)["owner"].(map[string]any)["username"] != "unkin" {
|
||||
t.Errorf("release refs: %v", body)
|
||||
}
|
||||
if code, _ := get(t, releases("0.1.0"), "v3/releases/unkin-hello-0.2.0"); code != http.StatusNotFound {
|
||||
t.Errorf("missing version: %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleObject(t *testing.T) {
|
||||
code, body := get(t, releases("0.9.0", "0.10.0"), "v3/modules/unkin-hello")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("code %d", code)
|
||||
}
|
||||
if body["slug"] != "unkin-hello" || body["name"] != "hello" || body["current_release"].(map[string]any)["version"] != "0.10.0" || len(body["releases"].([]any)) != 2 {
|
||||
t.Errorf("module: %v", body)
|
||||
}
|
||||
if code, _ := get(t, releases(), "v3/modules/unkin-missing"); code != http.StatusNotFound {
|
||||
t.Errorf("missing module: %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilesFallThrough(t *testing.T) {
|
||||
if code, _ := get(t, releases(), "v3/files/unkin-hello-0.1.0.tar.gz"); code != 0 {
|
||||
t.Error("file downloads must fall through to the stored file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareSemver(t *testing.T) {
|
||||
ordered := []string{"0.1.0", "0.2.0", "0.10.0", "1.0.0-alpha", "1.0.0-alpha.1", "1.0.0-beta", "1.0.0-beta.2", "1.0.0-beta.11", "1.0.0", "1.0.1+build"}
|
||||
for i := 1; i < len(ordered); i++ {
|
||||
if compareSemver(ordered[i-1], ordered[i]) >= 0 || compareSemver(ordered[i], ordered[i-1]) <= 0 {
|
||||
t.Errorf("%s should sort before %s", ordered[i-1], ordered[i])
|
||||
}
|
||||
}
|
||||
if compareSemver("1.0.0+a", "1.0.0+b") != 0 {
|
||||
t.Error("build metadata must not affect precedence")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const MaxAliasExpandedNodes = 100000
|
||||
|
||||
// ExpandYAMLAliases inlines alias targets and drops anchors so a node map
|
||||
// stays valid when copied away from the document that held its anchors;
|
||||
// budget caps nodes reached through aliases against alias bombs.
|
||||
func ExpandYAMLAliases(m map[string]yaml.Node, budget *int) (map[string]yaml.Node, error) {
|
||||
var expand func(n *yaml.Node, viaAlias bool) (*yaml.Node, error)
|
||||
expand = func(n *yaml.Node, viaAlias bool) (*yaml.Node, error) {
|
||||
if viaAlias {
|
||||
*budget--
|
||||
if *budget < 0 {
|
||||
return nil, errors.New("yaml aliases expand to too many nodes")
|
||||
}
|
||||
}
|
||||
if n.Kind == yaml.AliasNode {
|
||||
return expand(n.Alias, true)
|
||||
}
|
||||
c := *n
|
||||
c.Anchor = ""
|
||||
c.Content = make([]*yaml.Node, len(n.Content))
|
||||
for i, ch := range n.Content {
|
||||
var err error
|
||||
if c.Content[i], err = expand(ch, viaAlias); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
out := make(map[string]yaml.Node, len(m))
|
||||
for k, v := range m {
|
||||
e, err := expand(&v, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[k] = *e
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -21,19 +21,10 @@ import (
|
||||
"git.unkin.net/unkin/artifactapi/internal/database"
|
||||
"git.unkin.net/unkin/artifactapi/internal/gc"
|
||||
"git.unkin.net/unkin/artifactapi/internal/githubauth"
|
||||
_ "git.unkin.net/unkin/artifactapi/internal/provider/all"
|
||||
"git.unkin.net/unkin/artifactapi/internal/provider/alpine"
|
||||
_ "git.unkin.net/unkin/artifactapi/internal/provider/cargo"
|
||||
"git.unkin.net/unkin/artifactapi/internal/provider/deb"
|
||||
_ "git.unkin.net/unkin/artifactapi/internal/provider/docker"
|
||||
_ "git.unkin.net/unkin/artifactapi/internal/provider/generic"
|
||||
_ "git.unkin.net/unkin/artifactapi/internal/provider/goproxy"
|
||||
_ "git.unkin.net/unkin/artifactapi/internal/provider/helm"
|
||||
_ "git.unkin.net/unkin/artifactapi/internal/provider/npm"
|
||||
_ "git.unkin.net/unkin/artifactapi/internal/provider/nuget"
|
||||
_ "git.unkin.net/unkin/artifactapi/internal/provider/puppet"
|
||||
_ "git.unkin.net/unkin/artifactapi/internal/provider/pypi"
|
||||
"git.unkin.net/unkin/artifactapi/internal/provider/rpm"
|
||||
_ "git.unkin.net/unkin/artifactapi/internal/provider/terraform"
|
||||
"git.unkin.net/unkin/artifactapi/internal/proxy"
|
||||
"git.unkin.net/unkin/artifactapi/internal/storage"
|
||||
"git.unkin.net/unkin/artifactapi/internal/tfsign"
|
||||
@@ -175,6 +166,7 @@ func (s *Server) routes() chi.Router {
|
||||
r.Mount(tfregistry.MountPath, s.tfRegistry.Routes())
|
||||
|
||||
proxyHandler := v1.NewProxyHandler(s.engine, s.virtEngine, s.db, s.store, s.localHandler)
|
||||
s.virtEngine.NPMLocal = proxyHandler.LocalNPMPackument
|
||||
r.Mount("/api/v1", proxyHandler.Routes())
|
||||
r.Mount("/v2", proxyHandler.DockerV2Routes())
|
||||
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
package virtual
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"path"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// debIndexRe matches the binary Packages indexes a virtual merges: the flat
|
||||
// repo's root Packages and a suite's <component>/binary-<arch>/Packages.
|
||||
var debIndexRe = regexp.MustCompile(`^([^/]+/binary-[^/]+/)?Packages(\.[a-z0-9]+)?$`)
|
||||
|
||||
// debByHashRe matches an index requested by its SHA256 (Acquire-By-Hash).
|
||||
var debByHashRe = regexp.MustCompile(`^([^/]+/binary-[^/]+/)?by-hash/SHA256/[0-9a-f]{64}$`)
|
||||
|
||||
// debCompressions is the order a member's index variants are fetched in.
|
||||
var debCompressions = []string{".xz", ".gz", ".zst", ".bz2", ""}
|
||||
|
||||
// DebMember is one member's Release plus its decompressed Packages indexes,
|
||||
// keyed by index directory relative to the Release ("" for a flat repo).
|
||||
type DebMember struct {
|
||||
RemoteName string
|
||||
Release []byte
|
||||
Packages map[string][]byte
|
||||
}
|
||||
|
||||
// DebRepo is a merged apt repo: Release plus the indexes it lists, keyed by
|
||||
// their path relative to the Release.
|
||||
type DebRepo struct {
|
||||
Release []byte
|
||||
Files map[string][]byte
|
||||
}
|
||||
|
||||
type debRelease struct {
|
||||
fields map[string]string
|
||||
files []string
|
||||
}
|
||||
|
||||
// parseDebRelease reads a Release file's single-line fields and the names
|
||||
// listed in its checksum sections.
|
||||
func parseDebRelease(body []byte) debRelease {
|
||||
rel := debRelease{fields: map[string]string{}}
|
||||
inSums := false
|
||||
seen := map[string]bool{}
|
||||
for line := range strings.SplitSeq(string(body), "\n") {
|
||||
line = strings.TrimRight(line, "\r")
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if line[0] == ' ' || line[0] == '\t' {
|
||||
if f := strings.Fields(line); inSums && len(f) == 3 && !seen[f[2]] {
|
||||
seen[f[2]] = true
|
||||
rel.files = append(rel.files, f[2])
|
||||
}
|
||||
continue
|
||||
}
|
||||
k, v, _ := strings.Cut(line, ":")
|
||||
v = strings.TrimSpace(v)
|
||||
inSums = v == "" && (k == "MD5Sum" || strings.HasPrefix(k, "SHA"))
|
||||
if _, ok := rel.fields[k]; !ok {
|
||||
rel.fields[k] = v
|
||||
}
|
||||
}
|
||||
return rel
|
||||
}
|
||||
|
||||
// debIndexFiles returns, per index directory, the variant of its Packages file
|
||||
// to fetch from a member's Release listing.
|
||||
func debIndexFiles(rel debRelease) map[string]string {
|
||||
listed := map[string]bool{}
|
||||
for _, f := range rel.files {
|
||||
listed[f] = true
|
||||
}
|
||||
out := map[string]string{}
|
||||
for _, f := range rel.files {
|
||||
m := debIndexRe.FindStringSubmatch(f)
|
||||
if m == nil || out[m[1]] != "" {
|
||||
continue
|
||||
}
|
||||
for _, c := range debCompressions {
|
||||
if listed[m[1]+"Packages"+c] {
|
||||
out[m[1]] = m[1] + "Packages" + c
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func splitStanzas(body []byte) []string {
|
||||
var out []string
|
||||
for s := range strings.SplitSeq(strings.ReplaceAll(string(body), "\r\n", "\n"), "\n\n") {
|
||||
if s = strings.Trim(s, "\n"); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func stanzaField(stanza, key string) string {
|
||||
for line := range strings.SplitSeq(stanza, "\n") {
|
||||
if k, v, ok := strings.Cut(line, ":"); ok && k == key {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func unionFields(dst []string, field string) []string {
|
||||
for f := range strings.FieldsSeq(field) {
|
||||
if !slices.Contains(dst, f) {
|
||||
dst = append(dst, f)
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
func parseDebDate(s string) (time.Time, bool) {
|
||||
for _, layout := range []string{time.RFC1123Z, time.RFC1123} {
|
||||
if t, err := time.Parse(layout, s); err == nil {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
// MergeDeb merges members into one apt repo. Members are in priority order: the
|
||||
// first member to carry a (Package, Version, Architecture) in an index wins;
|
||||
// stanzas missing any of those are always kept. Filename is prefixed with the
|
||||
// owning member's name so the virtual can route downloads back to it.
|
||||
func MergeDeb(members []DebMember) *DebRepo {
|
||||
var suite, codename string
|
||||
var arches, comps, dirs []string
|
||||
date := time.Unix(0, 0)
|
||||
for _, m := range members {
|
||||
rel := parseDebRelease(m.Release)
|
||||
if suite == "" {
|
||||
suite, codename = rel.fields["Suite"], rel.fields["Codename"]
|
||||
}
|
||||
arches = unionFields(arches, rel.fields["Architectures"])
|
||||
comps = unionFields(comps, rel.fields["Components"])
|
||||
if t, ok := parseDebDate(rel.fields["Date"]); ok && t.After(date) {
|
||||
date = t
|
||||
}
|
||||
for d := range m.Packages {
|
||||
if !slices.Contains(dirs, d) {
|
||||
dirs = append(dirs, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
slices.Sort(dirs)
|
||||
|
||||
repo := &DebRepo{Files: map[string][]byte{}}
|
||||
for _, d := range dirs {
|
||||
seen := map[string]bool{}
|
||||
var b bytes.Buffer
|
||||
for _, m := range members {
|
||||
for _, s := range splitStanzas(m.Packages[d]) {
|
||||
pkg, ver, arch := stanzaField(s, "Package"), stanzaField(s, "Version"), stanzaField(s, "Architecture")
|
||||
if pkg != "" && ver != "" && arch != "" {
|
||||
key := pkg + "\x00" + ver + "\x00" + arch
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
}
|
||||
if fn := stanzaField(s, "Filename"); fn != "" {
|
||||
s = rewriteFilename(s, m.RemoteName+"/"+strings.TrimPrefix(path.Clean("/"+fn), "/"))
|
||||
}
|
||||
b.WriteString(s)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
}
|
||||
gz := gzipDeterministic(b.Bytes())
|
||||
repo.Files[d+"Packages"] = b.Bytes()
|
||||
repo.Files[d+"Packages.gz"] = gz
|
||||
repo.Files[d+"by-hash/SHA256/"+sha256Hex(b.Bytes())] = b.Bytes()
|
||||
repo.Files[d+"by-hash/SHA256/"+sha256Hex(gz)] = gz
|
||||
}
|
||||
|
||||
var r bytes.Buffer
|
||||
if suite != "" {
|
||||
fmt.Fprintf(&r, "Suite: %s\n", suite)
|
||||
}
|
||||
if codename != "" {
|
||||
fmt.Fprintf(&r, "Codename: %s\n", codename)
|
||||
}
|
||||
fmt.Fprintf(&r, "Date: %s\n", date.UTC().Format(time.RFC1123Z))
|
||||
fmt.Fprintf(&r, "Architectures: %s\n", strings.Join(arches, " "))
|
||||
if len(comps) > 0 {
|
||||
fmt.Fprintf(&r, "Components: %s\n", strings.Join(comps, " "))
|
||||
}
|
||||
r.WriteString("Acquire-By-Hash: yes\n")
|
||||
for _, sec := range []struct {
|
||||
name string
|
||||
sum func([]byte) string
|
||||
}{{"MD5Sum", md5Hex}, {"SHA256", sha256Hex}} {
|
||||
r.WriteString(sec.name + ":\n")
|
||||
for _, d := range dirs {
|
||||
for _, n := range []string{d + "Packages", d + "Packages.gz"} {
|
||||
fmt.Fprintf(&r, " %s %d %s\n", sec.sum(repo.Files[n]), len(repo.Files[n]), n)
|
||||
}
|
||||
}
|
||||
}
|
||||
repo.Release = r.Bytes()
|
||||
return repo
|
||||
}
|
||||
|
||||
func rewriteFilename(stanza, fn string) string {
|
||||
lines := strings.Split(stanza, "\n")
|
||||
for i, l := range lines {
|
||||
if strings.HasPrefix(l, "Filename:") {
|
||||
lines[i] = "Filename: " + fn
|
||||
break
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// debIndexPath splits an index request into its Release directory and the file
|
||||
// under it. ok is false for anything a deb virtual does not merge.
|
||||
func debIndexPath(p string) (base, name string, ok bool) {
|
||||
if rest, found := strings.CutPrefix(p, "dists/"); found {
|
||||
suite, n, found := strings.Cut(rest, "/")
|
||||
if !found || suite == "" {
|
||||
return "", "", false
|
||||
}
|
||||
base, p = "dists/"+suite+"/", n
|
||||
}
|
||||
if p != "Release" && !debIndexRe.MatchString(p) && !debByHashRe.MatchString(p) {
|
||||
return "", "", false
|
||||
}
|
||||
return base, p, true
|
||||
}
|
||||
|
||||
func md5Hex(data []byte) string {
|
||||
h := md5.Sum(data)
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
package virtual
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"maps"
|
||||
"path"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func debStanza(pkg, ver, arch, filename string) string {
|
||||
return fmt.Sprintf("Package: %s\nVersion: %s\nArchitecture: %s\nDescription: test\n multi-line body\nFilename: %s\nSize: 1\n", pkg, ver, arch, filename)
|
||||
}
|
||||
|
||||
func flatDebMember(name, date string, stanzas ...string) DebMember {
|
||||
rel := "Date: " + date + "\nArchitectures: amd64\nAcquire-By-Hash: no\nSHA256:\n x 1 Packages\n x 1 Packages.gz\n"
|
||||
return DebMember{RemoteName: name, Release: []byte(rel), Packages: map[string][]byte{"": []byte(strings.Join(stanzas, "\n"))}}
|
||||
}
|
||||
|
||||
func TestMergeDebDedupesByMemberOrder(t *testing.T) {
|
||||
repo := MergeDeb([]DebMember{
|
||||
flatDebMember("a", "Mon, 05 Oct 2026 10:00:00 +0000",
|
||||
debStanza("foo", "1.0", "amd64", "pool/foo_1.0_amd64.deb"),
|
||||
debStanza("bar", "2.0", "amd64", "/pool/bar.deb"),
|
||||
debStanza("dot", "1.0", "amd64", "./dot.deb"),
|
||||
"Package: noversion\nFilename: nv1.deb\n",
|
||||
"Package: noversion\nFilename: nv2.deb\n"),
|
||||
flatDebMember("b", "Tue, 06 Oct 2026 10:00:00 UTC",
|
||||
debStanza("foo", "1.0", "amd64", "pool/foo-from-b.deb"),
|
||||
debStanza("foo", "1.0", "arm64", "pool/foo_arm64.deb"),
|
||||
debStanza("foo", "1.1", "amd64", "o/r/releases/download/v1/foo.deb")),
|
||||
})
|
||||
pk := string(repo.Files["Packages"])
|
||||
stanzas := splitStanzas(repo.Files["Packages"])
|
||||
if len(stanzas) != 7 {
|
||||
t.Fatalf("want 7 stanzas (one duplicate dropped), got %d:\n%s", len(stanzas), pk)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Filename: a/pool/foo_1.0_amd64.deb",
|
||||
"Filename: a/pool/bar.deb",
|
||||
"Filename: a/dot.deb",
|
||||
"Filename: a/nv1.deb",
|
||||
"Filename: a/nv2.deb",
|
||||
"Filename: b/pool/foo_arm64.deb",
|
||||
"Filename: b/o/r/releases/download/v1/foo.deb",
|
||||
" multi-line body",
|
||||
} {
|
||||
if !strings.Contains(pk, want) {
|
||||
t.Errorf("merged Packages missing %q:\n%s", want, pk)
|
||||
}
|
||||
}
|
||||
if strings.Contains(pk, "foo-from-b") {
|
||||
t.Fatalf("second member's duplicate must lose:\n%s", pk)
|
||||
}
|
||||
if !bytes.Equal(gunzip(t, repo.Files["Packages.gz"]), repo.Files["Packages"]) {
|
||||
t.Fatal("Packages.gz does not match Packages")
|
||||
}
|
||||
if !strings.Contains(string(repo.Release), "Date: Tue, 06 Oct 2026 10:00:00 +0000\n") {
|
||||
t.Fatalf("Date should be the newest member date:\n%s", repo.Release)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeDebReleaseHashesFiles(t *testing.T) {
|
||||
repo := MergeDeb([]DebMember{flatDebMember("a", "", debStanza("foo", "1", "amd64", "pool/foo.deb"))})
|
||||
rel := parseDebRelease(repo.Release)
|
||||
if got := slices.Sorted(slices.Values(rel.files)); !slices.Equal(got, []string{"Packages", "Packages.gz"}) {
|
||||
t.Fatalf("Release lists %v", got)
|
||||
}
|
||||
inSHA := false
|
||||
checked := 0
|
||||
for line := range strings.SplitSeq(string(repo.Release), "\n") {
|
||||
if !strings.HasPrefix(line, " ") {
|
||||
inSHA = line == "SHA256:"
|
||||
continue
|
||||
}
|
||||
var sum, name string
|
||||
var size int
|
||||
if _, err := fmt.Sscan(line, &sum, &size, &name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := repo.Files[name]
|
||||
want := sha256Hex(body)
|
||||
if !inSHA {
|
||||
h := md5.Sum(body)
|
||||
want = hex.EncodeToString(h[:])
|
||||
}
|
||||
if sum != want || size != len(body) {
|
||||
t.Errorf("%s: Release has %s %d, file is %s %d", name, sum, size, want, len(body))
|
||||
}
|
||||
checked++
|
||||
}
|
||||
if checked != 4 {
|
||||
t.Fatalf("checked %d Release entries, want 4:\n%s", checked, repo.Release)
|
||||
}
|
||||
if !bytes.Equal(MergeDeb([]DebMember{flatDebMember("a", "", debStanza("foo", "1", "amd64", "pool/foo.deb"))}).Release, repo.Release) {
|
||||
t.Fatal("merge must be deterministic")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeDebSuiteLayout(t *testing.T) {
|
||||
rel := func(comps string) []byte {
|
||||
return []byte("Suite: stable\nCodename: trixie\nArchitectures: amd64 arm64\nComponents: " + comps + "\nSHA256:\n x 1 main/binary-amd64/Packages.xz\n")
|
||||
}
|
||||
repo := MergeDeb([]DebMember{
|
||||
{RemoteName: "deb1", Release: rel("main"), Packages: map[string][]byte{"main/binary-amd64/": []byte(debStanza("foo", "1", "amd64", "pool/main/f/foo.deb"))}},
|
||||
{RemoteName: "deb2", Release: rel("main contrib"), Packages: map[string][]byte{
|
||||
"main/binary-amd64/": []byte(debStanza("bar", "1", "amd64", "pool/main/b/bar.deb")),
|
||||
"contrib/binary-amd64/": []byte(debStanza("baz", "1", "amd64", "pool/contrib/b/baz.deb")),
|
||||
}},
|
||||
})
|
||||
for _, want := range []string{"Suite: stable\n", "Codename: trixie\n", "Components: main contrib\n", "Architectures: amd64 arm64\n"} {
|
||||
if !strings.Contains(string(repo.Release), want) {
|
||||
t.Errorf("Release missing %q:\n%s", want, repo.Release)
|
||||
}
|
||||
}
|
||||
want := []string{"contrib/binary-amd64/Packages", "contrib/binary-amd64/Packages.gz", "main/binary-amd64/Packages", "main/binary-amd64/Packages.gz"}
|
||||
if got := slices.Sorted(slices.Values(parseDebRelease(repo.Release).files)); !slices.Equal(got, want) {
|
||||
t.Fatalf("Release lists %v, want %v", got, want)
|
||||
}
|
||||
if !strings.Contains(string(repo.Release), "Acquire-By-Hash: yes\n") {
|
||||
t.Fatalf("Release must advertise by-hash:\n%s", repo.Release)
|
||||
}
|
||||
for _, n := range want {
|
||||
byHash := path.Dir(n) + "/by-hash/SHA256/" + sha256Hex(repo.Files[n])
|
||||
if !bytes.Equal(repo.Files[byHash], repo.Files[n]) {
|
||||
t.Errorf("%s not served at %s", n, byHash)
|
||||
}
|
||||
}
|
||||
if len(repo.Files) != 2*len(want) {
|
||||
t.Fatalf("files %v, want each index plus its by-hash copy", slices.Sorted(maps.Keys(repo.Files)))
|
||||
}
|
||||
if n := len(splitStanzas(repo.Files["main/binary-amd64/Packages"])); n != 2 {
|
||||
t.Fatalf("main/binary-amd64 should merge both members, got %d stanzas", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDebIndexFiles(t *testing.T) {
|
||||
rel := parseDebRelease([]byte("Origin: Debian\nMD5Sum:\n a 1 main/binary-amd64/Packages\n a 1 main/binary-amd64/Packages.gz\nSHA256:\n" +
|
||||
" a 1 main/binary-amd64/Packages\n a 1 main/binary-amd64/Packages.xz\n a 1 main/binary-amd64/Packages.gz\n" +
|
||||
" a 1 main/debian-installer/binary-amd64/Packages.xz\n a 1 main/i18n/Translation-en.xz\n a 1 contrib/binary-all/Packages\n"))
|
||||
got := debIndexFiles(rel)
|
||||
want := map[string]string{"main/binary-amd64/": "main/binary-amd64/Packages.xz", "contrib/binary-all/": "contrib/binary-all/Packages"}
|
||||
if !maps.Equal(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
if flat := debIndexFiles(parseDebRelease([]byte("SHA256:\n a 1 Packages\n a 1 Packages.gz\n"))); flat[""] != "Packages.gz" {
|
||||
t.Fatalf("flat repo should fetch Packages.gz, got %v", flat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDebIndexPath(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
in, base, name string
|
||||
ok bool
|
||||
}{
|
||||
{"Release", "", "Release", true},
|
||||
{"Packages.gz", "", "Packages.gz", true},
|
||||
{"dists/trixie/Release", "dists/trixie/", "Release", true},
|
||||
{"dists/trixie/main/binary-amd64/Packages", "dists/trixie/", "main/binary-amd64/Packages", true},
|
||||
{"by-hash/SHA256/abababababababababababababababababababababababababababababababab", "", "by-hash/SHA256/abababababababababababababababababababababababababababababababab", true},
|
||||
{"dists/trixie/main/binary-amd64/by-hash/SHA256/abababababababababababababababababababababababababababababababab", "dists/trixie/", "main/binary-amd64/by-hash/SHA256/abababababababababababababababababababababababababababababababab", true},
|
||||
{"dists/trixie/main/binary-amd64/by-hash/MD5Sum/abababababababababababababababab", "", "", false},
|
||||
{"InRelease", "", "", false},
|
||||
{"Release.gpg", "", "", false},
|
||||
{"dists/trixie/InRelease", "", "", false},
|
||||
{"dists/trixie/main/i18n/Translation-en", "", "", false},
|
||||
{"dists/Release", "", "", false},
|
||||
{"pool/foo.deb", "", "", false},
|
||||
} {
|
||||
base, name, ok := debIndexPath(tc.in)
|
||||
if base != tc.base || name != tc.name || ok != tc.ok {
|
||||
t.Errorf("%s: got (%q, %q, %v), want (%q, %q, %v)", tc.in, base, name, ok, tc.base, tc.name, tc.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
-105
@@ -9,7 +9,6 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -27,7 +26,11 @@ type Engine struct {
|
||||
proxyEngine *proxy.Engine
|
||||
getRemote func(context.Context, string) (*models.Remote, error)
|
||||
rpmMember func(context.Context, string) (*RPMMember, error)
|
||||
debMember func(ctx context.Context, name, base string) (*DebMember, error)
|
||||
npmMember func(ctx context.Context, name, pkg string) (*NPMMember, error)
|
||||
|
||||
// NPMLocal returns a local npm repo's packument with tarball paths relative
|
||||
// to the repo, or ErrNotFound.
|
||||
NPMLocal func(ctx context.Context, repo, pkg string) ([]byte, error)
|
||||
|
||||
mergeTTL time.Duration
|
||||
|
||||
@@ -44,34 +47,26 @@ type mergedRepo interface {
|
||||
|
||||
func (r *RPMRepo) index() []byte { return r.Repomd }
|
||||
func (r *RPMRepo) files() map[string][]byte { return r.Files }
|
||||
func (r *DebRepo) index() []byte { return r.Release }
|
||||
func (r *DebRepo) files() map[string][]byte { return r.Files }
|
||||
|
||||
// repoGen holds a virtual's current merge plus the previous one, so a client
|
||||
// holding the previous index can still fetch the files it references.
|
||||
type repoGen struct {
|
||||
cur, prev mergedRepo
|
||||
at, used time.Time
|
||||
at time.Time
|
||||
}
|
||||
|
||||
const rpmMergeTTL = 60 * time.Second
|
||||
|
||||
// maxDebSuites bounds the client-chosen suites cached per deb virtual.
|
||||
const maxDebSuites = 32
|
||||
|
||||
func NewEngine(db *database.DB, proxyEngine *proxy.Engine) *Engine {
|
||||
e := &Engine{db: db, proxyEngine: proxyEngine, getRemote: db.GetRemote, mergeTTL: rpmMergeTTL}
|
||||
e.rpmMember = e.fetchRPMMember
|
||||
e.debMember = e.fetchDebMember
|
||||
e.npmMember = e.fetchNPMMember
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *Engine) Fetch(ctx context.Context, virt models.Virtual, path string, proxyBaseURL string) ([]byte, string, error) {
|
||||
switch virt.PackageType {
|
||||
case models.PackageRPM:
|
||||
if virt.PackageType == models.PackageRPM {
|
||||
return e.fetchRPM(ctx, virt, path)
|
||||
case models.PackageDeb:
|
||||
return e.fetchDeb(ctx, virt, path)
|
||||
}
|
||||
|
||||
merger, err := GetMerger(virt.PackageType)
|
||||
@@ -190,8 +185,8 @@ var (
|
||||
ErrBadPath = errors.New("invalid path")
|
||||
)
|
||||
|
||||
// MemberRedirect maps an rpm or deb virtual package path (<member>/<href>, as
|
||||
// written by MergeRPM/MergeDeb) to the owning member's route. ok is false for
|
||||
// MemberRedirect maps an rpm or npm virtual package path (<member>/<href>, as
|
||||
// written by MergeRPM/MergeNPM) to the owning member's route. ok is false for
|
||||
// any other path; ErrBadPath means the path is absolute or escapes the member.
|
||||
func (e *Engine) MemberRedirect(ctx context.Context, virt models.Virtual, path, rawQuery, proxyBaseURL string) (string, bool, error) {
|
||||
switch virt.PackageType {
|
||||
@@ -199,12 +194,7 @@ func (e *Engine) MemberRedirect(ctx context.Context, virt models.Virtual, path,
|
||||
if strings.HasPrefix(path, "repodata/") {
|
||||
return "", false, nil
|
||||
}
|
||||
case models.PackageDeb:
|
||||
// apt requests a flat repo's files under its "./" dist verbatim.
|
||||
path = strings.TrimPrefix(path, "./")
|
||||
if strings.HasPrefix(path, "dists/") {
|
||||
return "", false, nil
|
||||
}
|
||||
case models.PackageNPM:
|
||||
default:
|
||||
return "", false, nil
|
||||
}
|
||||
@@ -279,7 +269,6 @@ func (e *Engine) merged(ctx context.Context, key string, merge func(context.Cont
|
||||
e.mu.Lock()
|
||||
g := e.gens[key]
|
||||
if g != nil && time.Since(g.at) < e.mergeTTL {
|
||||
g.used = time.Now()
|
||||
e.mu.Unlock()
|
||||
return g.cur, nil
|
||||
}
|
||||
@@ -298,12 +287,12 @@ func (e *Engine) merged(ctx context.Context, key string, merge func(context.Cont
|
||||
g := e.gens[key]
|
||||
switch {
|
||||
case g == nil:
|
||||
e.gens[key] = &repoGen{cur: repo, at: time.Now(), used: time.Now()}
|
||||
e.gens[key] = &repoGen{cur: repo, at: time.Now()}
|
||||
case string(g.cur.index()) == string(repo.index()):
|
||||
g.at, g.used = time.Now(), time.Now()
|
||||
g.at = time.Now()
|
||||
repo = g.cur
|
||||
default:
|
||||
g.prev, g.cur, g.at, g.used = g.cur, repo, time.Now(), time.Now()
|
||||
g.prev, g.cur, g.at = g.cur, repo, time.Now()
|
||||
}
|
||||
return repo, nil
|
||||
})
|
||||
@@ -350,85 +339,6 @@ func fetchMembers[T any](virt models.Virtual, fetch func(name string) (*T, error
|
||||
return members, nil
|
||||
}
|
||||
|
||||
func (e *Engine) fetchDeb(ctx context.Context, virt models.Virtual, reqPath string) ([]byte, string, error) {
|
||||
base, name, ok := debIndexPath(strings.TrimPrefix(path.Clean("/"+reqPath), "/"))
|
||||
if !ok {
|
||||
return nil, "", ErrNotFound
|
||||
}
|
||||
key := "deb\x00" + virt.Name + "\x00" + base
|
||||
if name != "Release" {
|
||||
if body, ok := e.cachedFile(key, name); ok {
|
||||
return body, debContentType(name), nil
|
||||
}
|
||||
}
|
||||
|
||||
repo, err := e.merged(ctx, key, func(ctx context.Context) (mergedRepo, error) {
|
||||
members, err := fetchMembers(virt, func(name string) (*DebMember, error) { return e.debMember(ctx, name, base) })
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return MergeDeb(members), nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
e.evictLRU("deb\x00"+virt.Name+"\x00", maxDebSuites)
|
||||
if name == "Release" {
|
||||
return repo.index(), "text/plain", nil
|
||||
}
|
||||
if body, ok := repo.files()[name]; ok {
|
||||
return body, debContentType(name), nil
|
||||
}
|
||||
return nil, "", ErrNotFound
|
||||
}
|
||||
|
||||
func debContentType(name string) string {
|
||||
if strings.HasSuffix(name, ".gz") {
|
||||
return "application/gzip"
|
||||
}
|
||||
return "text/plain"
|
||||
}
|
||||
|
||||
// fetchDebMember reads a member's Release under base and each binary Packages
|
||||
// index it lists.
|
||||
func (e *Engine) fetchDebMember(ctx context.Context, name, base string) (*DebMember, error) {
|
||||
remote, err := e.getRemote(ctx, name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("remote %q: %w", name, err)
|
||||
}
|
||||
rel, err := e.fetchMemberPath(ctx, *remote, base+"Release")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m := &DebMember{RemoteName: name, Release: rel, Packages: map[string][]byte{}}
|
||||
for dir, file := range debIndexFiles(parseDebRelease(rel)) {
|
||||
raw, err := e.fetchMemberPath(ctx, *remote, base+file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if m.Packages[dir], err = decompress(file, raw); err != nil {
|
||||
return nil, fmt.Errorf("decompress %s: %w", file, err)
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// evictLRU drops the least recently used merges under prefix beyond keep.
|
||||
func (e *Engine) evictLRU(prefix string, keep int) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
var keys []string
|
||||
for k := range e.gens {
|
||||
if strings.HasPrefix(k, prefix) {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
slices.SortFunc(keys, func(a, b string) int { return e.gens[b].used.Compare(e.gens[a].used) })
|
||||
for _, k := range keys[min(len(keys), keep):] {
|
||||
delete(e.gens, k)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) cachedFile(key, path string) ([]byte, bool) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
@@ -441,7 +351,6 @@ func (e *Engine) cachedFile(key, path string) ([]byte, bool) {
|
||||
continue
|
||||
}
|
||||
if body, ok := r.files()[path]; ok {
|
||||
g.used = time.Now()
|
||||
return body, true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
@@ -158,97 +157,6 @@ func TestMemberRedirect(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func debEngine(members map[string]*DebMember) *Engine {
|
||||
e := fakeEngine(nil)
|
||||
e.debMember = func(_ context.Context, name, base string) (*DebMember, error) {
|
||||
if m := members[name]; m != nil && base == "" {
|
||||
return m, nil
|
||||
}
|
||||
return nil, errors.New("upstream down")
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
func debVirt(members ...string) models.Virtual {
|
||||
return models.Virtual{Name: "v", PackageType: models.PackageDeb, Members: members}
|
||||
}
|
||||
|
||||
func TestFetchDebFailsClosed(t *testing.T) {
|
||||
a := flatDebMember("a", "", debStanza("foo", "1", "amd64", "pool/foo.deb"))
|
||||
e := debEngine(map[string]*DebMember{"a": &a})
|
||||
for _, p := range []string{"Release", "./Packages.gz", "dists/trixie/Release"} {
|
||||
if _, _, err := e.Fetch(context.Background(), debVirt("a", "b"), p, ""); err == nil || errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("%s: want upstream error with a member down, got %v", p, err)
|
||||
}
|
||||
}
|
||||
for _, p := range []string{"InRelease", "./Release.gpg", "Packages.xz"} {
|
||||
if _, _, err := e.Fetch(context.Background(), debVirt("a"), p, ""); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("%s: want not found, got %v", p, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchDebRemergesAfterTTL(t *testing.T) {
|
||||
a := flatDebMember("a", "", debStanza("foo", "1", "amd64", "pool/foo_1.deb"))
|
||||
e := debEngine(map[string]*DebMember{"a": &a})
|
||||
virt := debVirt("a")
|
||||
|
||||
rel, _, err := e.Fetch(context.Background(), virt, "./Release", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.Packages[""] = []byte(debStanza("foo", "2", "amd64", "pool/foo_2.deb"))
|
||||
e.mergeTTL = 0
|
||||
newRel, _, err := e.Fetch(context.Background(), virt, "Release", "")
|
||||
if err != nil || string(newRel) == string(rel) {
|
||||
t.Fatalf("Release should reflect the member change (err %v)", err)
|
||||
}
|
||||
pk, ct, err := e.Fetch(context.Background(), virt, "./Packages", "")
|
||||
if err != nil || ct != "text/plain" || !strings.Contains(string(pk), "Version: 2") {
|
||||
t.Fatalf("Packages should be the current merge (ct %q, err %v):\n%s", ct, err, pk)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchDebMergeIsSharedPerSuite(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
e := fakeEngine(nil)
|
||||
e.mergeTTL = time.Minute
|
||||
e.debMember = func(_ context.Context, name, base string) (*DebMember, error) {
|
||||
calls.Add(1)
|
||||
m := flatDebMember(name, "", debStanza("foo", "1", "amd64", "pool/foo.deb"))
|
||||
return &m, nil
|
||||
}
|
||||
for _, p := range []string{"Release", "Packages", "Packages.gz", "./Release", "dists/a/Release", "dists/b/Release"} {
|
||||
if _, _, err := e.Fetch(context.Background(), debVirt("a"), p, ""); err != nil && !errors.Is(err, ErrNotFound) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if n := calls.Load(); n != 3 {
|
||||
t.Fatalf("member fetched %d times, want once per Release dir (3)", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemberRedirectDeb(t *testing.T) {
|
||||
e := fakeEngine(nil)
|
||||
virt := debVirt("gh", "loc")
|
||||
for _, tc := range []struct {
|
||||
path, want string
|
||||
ok bool
|
||||
err error
|
||||
}{
|
||||
{path: "gh/o/r/releases/download/v1/foo_1.0_amd64.deb", ok: true, want: "http://h/api/v1/remote/gh/o/r/releases/download/v1/foo_1.0_amd64.deb"},
|
||||
{path: "./gh/pool/a+b.deb", ok: true, want: "http://h/api/v1/remote/gh/pool/a+b.deb"},
|
||||
{path: "gh/../loc/x.deb", err: ErrBadPath},
|
||||
{path: "dists/trixie/Release"},
|
||||
{path: "Packages"},
|
||||
} {
|
||||
got, ok, err := e.MemberRedirect(context.Background(), virt, tc.path, "", "http://h/")
|
||||
if got != tc.want || ok != tc.ok || !errors.Is(err, tc.err) {
|
||||
t.Errorf("%s: got (%q, %v, %v), want (%q, %v, %v)", tc.path, got, ok, err, tc.want, tc.ok, tc.err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type fakeRepo struct {
|
||||
idx string
|
||||
fs map[string][]byte
|
||||
@@ -298,72 +206,3 @@ func TestMergedReusesWithinTTLAndIsolatesKeys(t *testing.T) {
|
||||
t.Error("unknown key served a file")
|
||||
}
|
||||
}
|
||||
|
||||
func suiteDebMember(name, version string) *DebMember {
|
||||
return &DebMember{RemoteName: name, Release: []byte("Suite: s\nSHA256:\n x 1 main/binary-amd64/Packages.gz\n"),
|
||||
Packages: map[string][]byte{"main/binary-amd64/": []byte(debStanza("foo", version, "amd64", "pool/foo_"+version+".deb"))}}
|
||||
}
|
||||
|
||||
func TestFetchDebByHashSurvivesRemerge(t *testing.T) {
|
||||
version := "1"
|
||||
e := fakeEngine(nil)
|
||||
e.debMember = func(_ context.Context, name, _ string) (*DebMember, error) { return suiteDebMember(name, version), nil }
|
||||
virt := debVirt("a")
|
||||
fetch := func(p string) []byte {
|
||||
body, _, err := e.Fetch(context.Background(), virt, p, "")
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", p, err)
|
||||
}
|
||||
return body
|
||||
}
|
||||
byHash := func(rel []byte) string {
|
||||
m := regexp.MustCompile(`(?m)^SHA256:\n(?: .*\n)*? ([0-9a-f]{64}) \d+ main/binary-amd64/Packages.gz$`).FindSubmatch(rel)
|
||||
if m == nil {
|
||||
t.Fatalf("no Packages.gz SHA256 in Release:\n%s", rel)
|
||||
}
|
||||
return "dists/trixie/main/binary-amd64/by-hash/SHA256/" + string(m[1])
|
||||
}
|
||||
|
||||
oldRel := fetch("dists/trixie/Release")
|
||||
version, e.mergeTTL = "2", 0
|
||||
if newRel := fetch("dists/trixie/Release"); string(newRel) == string(oldRel) {
|
||||
t.Fatal("Release should reflect the member change")
|
||||
}
|
||||
if got := string(gunzip(t, fetch(byHash(oldRel)))); !strings.Contains(got, "Version: 1\n") {
|
||||
t.Fatalf("by-hash from the previous Release served the wrong index:\n%s", got)
|
||||
}
|
||||
if got := string(fetch("dists/trixie/main/binary-amd64/Packages")); !strings.Contains(got, "Version: 2\n") {
|
||||
t.Fatalf("fixed-name Packages should be the current merge:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchDebBoundsSuitesPerVirtual(t *testing.T) {
|
||||
e := fakeEngine(nil)
|
||||
e.mergeTTL = time.Minute
|
||||
e.debMember = func(_ context.Context, name, _ string) (*DebMember, error) { return suiteDebMember(name, "1"), nil }
|
||||
other := models.Virtual{Name: "other", PackageType: models.PackageDeb, Members: []string{"a"}}
|
||||
if _, _, err := e.Fetch(context.Background(), other, "dists/keep/Release", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := range maxDebSuites + 5 {
|
||||
if _, _, err := e.Fetch(context.Background(), debVirt("a"), fmt.Sprintf("dists/s%d/Release", i), ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
time.Sleep(time.Microsecond)
|
||||
}
|
||||
n := 0
|
||||
for k := range e.gens {
|
||||
if strings.HasPrefix(k, "deb\x00v\x00") {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n != maxDebSuites {
|
||||
t.Fatalf("virtual v caches %d suites, want %d", n, maxDebSuites)
|
||||
}
|
||||
if e.gens["deb\x00v\x00dists/s0/"] != nil || e.gens["deb\x00v\x00dists/s4/"] != nil {
|
||||
t.Fatal("least recently used suites should be evicted first")
|
||||
}
|
||||
if e.gens["deb\x00other\x00dists/keep/"] == nil {
|
||||
t.Fatal("another virtual's suites must not be evicted")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,14 @@ package virtual
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/provider"
|
||||
"git.unkin.net/unkin/artifactapi/pkg/models"
|
||||
)
|
||||
|
||||
@@ -16,22 +20,17 @@ func init() {
|
||||
type HelmMerger struct{}
|
||||
|
||||
type helmIndex struct {
|
||||
APIVersion string `yaml:"apiVersion"`
|
||||
Entries map[string][]helmChartVersion `yaml:"entries"`
|
||||
Generated string `yaml:"generated,omitempty"`
|
||||
}
|
||||
|
||||
type helmChartVersion struct {
|
||||
Name string `yaml:"name"`
|
||||
Version string `yaml:"version"`
|
||||
URLs []string `yaml:"urls"`
|
||||
rest map[string]any
|
||||
APIVersion string `yaml:"apiVersion"`
|
||||
Entries map[string][]map[string]yaml.Node `yaml:"entries"`
|
||||
Generated string `yaml:"generated,omitempty"`
|
||||
}
|
||||
|
||||
// MergeIndexes keeps each chart version's fields as raw nodes so digest,
|
||||
// apiVersion and the rest survive the merge untouched; only urls are rewritten.
|
||||
func (m *HelmMerger) MergeIndexes(members []MemberIndex, proxyBaseURL string) ([]byte, error) {
|
||||
merged := &helmIndex{
|
||||
APIVersion: "v1",
|
||||
Entries: make(map[string][]helmChartVersion),
|
||||
Entries: make(map[string][]map[string]yaml.Node),
|
||||
}
|
||||
|
||||
seen := map[string]map[string]bool{}
|
||||
@@ -42,47 +41,38 @@ func (m *HelmMerger) MergeIndexes(members []MemberIndex, proxyBaseURL string) ([
|
||||
continue
|
||||
}
|
||||
|
||||
for chart, versions := range idx.Entries {
|
||||
budget := provider.MaxAliasExpandedNodes
|
||||
for _, chart := range slices.Sorted(maps.Keys(idx.Entries)) {
|
||||
versions := idx.Entries[chart]
|
||||
if seen[chart] == nil {
|
||||
seen[chart] = map[string]bool{}
|
||||
}
|
||||
for _, ver := range versions {
|
||||
key := chart + ":" + ver.Version
|
||||
if seen[chart][ver.Version] {
|
||||
for _, raw := range versions {
|
||||
ver, err := provider.ExpandYAMLAliases(raw, &budget)
|
||||
if err != nil {
|
||||
slog.Warn("helm merge: dropping chart entry", "member", member.RemoteName, "chart", chart, "error", err)
|
||||
continue
|
||||
}
|
||||
seen[chart][ver.Version] = true
|
||||
version := ver["version"].Value
|
||||
if seen[chart][version] {
|
||||
continue
|
||||
}
|
||||
seen[chart][version] = true
|
||||
|
||||
if proxyBaseURL != "" {
|
||||
routePrefix := "remote"
|
||||
if member.RepoType == "local" {
|
||||
routePrefix = "local"
|
||||
if urlsNode, ok := ver["urls"]; ok && proxyBaseURL != "" {
|
||||
var urls []string
|
||||
if err := urlsNode.Decode(&urls); err != nil {
|
||||
continue
|
||||
}
|
||||
baseHost := extractHost(member.BaseURL)
|
||||
|
||||
for i, u := range ver.URLs {
|
||||
if strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://") {
|
||||
if baseHost != "" && extractHost(u) != baseHost {
|
||||
continue
|
||||
}
|
||||
relPath := extractPathRelativeToBase(u, member.BaseURL)
|
||||
ver.URLs[i] = fmt.Sprintf("%s/api/v1/%s/%s/%s",
|
||||
strings.TrimRight(proxyBaseURL, "/"),
|
||||
routePrefix,
|
||||
member.RemoteName,
|
||||
relPath)
|
||||
} else {
|
||||
ver.URLs[i] = fmt.Sprintf("%s/api/v1/%s/%s/%s",
|
||||
strings.TrimRight(proxyBaseURL, "/"),
|
||||
routePrefix,
|
||||
member.RemoteName,
|
||||
u)
|
||||
}
|
||||
rewriteHelmURLs(urls, member, proxyBaseURL)
|
||||
var n yaml.Node
|
||||
if err := n.Encode(urls); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ver["urls"] = n
|
||||
}
|
||||
|
||||
merged.Entries[chart] = append(merged.Entries[chart], ver)
|
||||
_ = key
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,6 +80,34 @@ func (m *HelmMerger) MergeIndexes(members []MemberIndex, proxyBaseURL string) ([
|
||||
return yaml.Marshal(merged)
|
||||
}
|
||||
|
||||
func rewriteHelmURLs(urls []string, member MemberIndex, proxyBaseURL string) {
|
||||
routePrefix := "remote"
|
||||
if member.RepoType == "local" {
|
||||
routePrefix = "local"
|
||||
}
|
||||
baseHost := extractHost(member.BaseURL)
|
||||
|
||||
for i, u := range urls {
|
||||
if strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://") {
|
||||
if baseHost != "" && extractHost(u) != baseHost {
|
||||
continue
|
||||
}
|
||||
relPath := extractPathRelativeToBase(u, member.BaseURL)
|
||||
urls[i] = fmt.Sprintf("%s/api/v1/%s/%s/%s",
|
||||
strings.TrimRight(proxyBaseURL, "/"),
|
||||
routePrefix,
|
||||
member.RemoteName,
|
||||
relPath)
|
||||
} else {
|
||||
urls[i] = fmt.Sprintf("%s/api/v1/%s/%s/%s",
|
||||
strings.TrimRight(proxyBaseURL, "/"),
|
||||
routePrefix,
|
||||
member.RemoteName,
|
||||
u)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func extractHost(rawURL string) string {
|
||||
idx := strings.Index(rawURL, "://")
|
||||
if idx == -1 {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package virtual_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/virtual"
|
||||
)
|
||||
|
||||
@@ -122,3 +125,129 @@ entries:
|
||||
t.Error("expected fallback repo to be excluded for duplicate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelmMerger_PreservesChartFields(t *testing.T) {
|
||||
m := &virtual.HelmMerger{}
|
||||
body := []byte(`apiVersion: v1
|
||||
entries:
|
||||
app:
|
||||
- apiVersion: v2
|
||||
name: app
|
||||
version: "1.0"
|
||||
appVersion: "3.1"
|
||||
description: in-house
|
||||
digest: deadbeef
|
||||
urls:
|
||||
- app-1.0.tgz
|
||||
`)
|
||||
result, err := m.MergeIndexes([]virtual.MemberIndex{{RemoteName: "charts", RepoType: "local", Body: body}}, "https://proxy.example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := string(result)
|
||||
for _, want := range []string{
|
||||
"digest: deadbeef",
|
||||
"apiVersion: v2",
|
||||
`appVersion: "3.1"`,
|
||||
"description: in-house",
|
||||
`version: "1.0"`,
|
||||
"https://proxy.example.com/api/v1/local/charts/app-1.0.tgz",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("merged index missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelmMerger_ExpandsAliasesAcrossDedupe(t *testing.T) {
|
||||
m := &virtual.HelmMerger{}
|
||||
first := []byte(`apiVersion: v1
|
||||
entries:
|
||||
app:
|
||||
- version: "1.0"
|
||||
urls: [app-1.0.tgz]
|
||||
`)
|
||||
second := []byte(`apiVersion: v1
|
||||
entries:
|
||||
app:
|
||||
- version: "1.0"
|
||||
maintainers: &team
|
||||
- name: ops
|
||||
urls: [app-1.0.tgz]
|
||||
- version: "2.0"
|
||||
maintainers: *team
|
||||
urls: [app-2.0.tgz]
|
||||
`)
|
||||
result, err := m.MergeIndexes([]virtual.MemberIndex{
|
||||
{RemoteName: "a", RepoType: "local", Body: first},
|
||||
{RemoteName: "b", RepoType: "local", Body: second},
|
||||
}, "https://proxy.example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var idx struct {
|
||||
Entries map[string][]struct {
|
||||
Version string `yaml:"version"`
|
||||
Maintainers []struct {
|
||||
Name string `yaml:"name"`
|
||||
} `yaml:"maintainers"`
|
||||
} `yaml:"entries"`
|
||||
}
|
||||
if err := yaml.Unmarshal(result, &idx); err != nil {
|
||||
t.Fatalf("merged index does not parse: %v\n%s", err, result)
|
||||
}
|
||||
app := idx.Entries["app"]
|
||||
if len(app) != 2 || app[1].Version != "2.0" || len(app[1].Maintainers) != 1 || app[1].Maintainers[0].Name != "ops" {
|
||||
t.Errorf("alias not expanded: %+v\n%s", app, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelmMerger_DropsAliasBomb(t *testing.T) {
|
||||
m := &virtual.HelmMerger{}
|
||||
body := []byte(`apiVersion: v1
|
||||
entries:
|
||||
app:
|
||||
- version: "1.0"
|
||||
a: &a [x, x, x, x, x, x, x, x, x, x]
|
||||
b: &b [*a, *a, *a, *a, *a, *a, *a, *a, *a, *a]
|
||||
c: &c [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b]
|
||||
d: &d [*c, *c, *c, *c, *c, *c, *c, *c, *c, *c]
|
||||
e: &e [*d, *d, *d, *d, *d, *d, *d, *d, *d, *d]
|
||||
urls: [app-1.0.tgz]
|
||||
`)
|
||||
result, err := m.MergeIndexes([]virtual.MemberIndex{{RemoteName: "a", RepoType: "local", Body: body}}, "https://proxy.example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(result), "app-1.0.tgz") {
|
||||
t.Errorf("alias bomb entry was kept")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelmMerger_LargeIndexWithoutAliasesIsComplete(t *testing.T) {
|
||||
var b strings.Builder
|
||||
b.WriteString("apiVersion: v1\nentries:\n")
|
||||
for c := 0; c < 100; c++ {
|
||||
fmt.Fprintf(&b, " chart%d:\n", c)
|
||||
for v := 0; v < 100; v++ {
|
||||
fmt.Fprintf(&b, " - name: chart%d\n version: \"1.0.%d\"\n description: d\n maintainers: [{name: ops, email: ops@example.com}]\n urls: [chart%d-1.0.%d.tgz]\n", c, v, c, v)
|
||||
}
|
||||
}
|
||||
result, err := (&virtual.HelmMerger{}).MergeIndexes([]virtual.MemberIndex{{RemoteName: "a", RepoType: "local", Body: []byte(b.String())}}, "https://proxy.example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var idx struct {
|
||||
Entries map[string][]map[string]any `yaml:"entries"`
|
||||
}
|
||||
if err := yaml.Unmarshal(result, &idx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
total := 0
|
||||
for _, vs := range idx.Entries {
|
||||
total += len(vs)
|
||||
}
|
||||
if len(idx.Entries) != 100 || total != 10000 {
|
||||
t.Errorf("got %d charts, %d versions; want 100, 10000", len(idx.Entries), total)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
package virtual
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/proxy"
|
||||
"git.unkin.net/unkin/artifactapi/pkg/models"
|
||||
)
|
||||
|
||||
const (
|
||||
NPMAbbreviatedType = "application/vnd.npm.install-v1+json"
|
||||
npmAbbreviatedFile = "install-v1"
|
||||
maxNPMPackuments = 1024
|
||||
)
|
||||
|
||||
var npmVirtualNameRe = regexp.MustCompile(`^(@[A-Za-z0-9][A-Za-z0-9._~-]*/)?[A-Za-z0-9][A-Za-z0-9._~-]*$`)
|
||||
|
||||
// npmAbbreviatedFields are the version fields kept in an install-v1 packument.
|
||||
var npmAbbreviatedFields = []string{
|
||||
"name", "version", "deprecated", "dependencies", "optionalDependencies",
|
||||
"devDependencies", "bundleDependencies", "peerDependencies", "peerDependenciesMeta",
|
||||
"bin", "directories", "dist", "engines", "_hasShrinkwrap", "hasInstallScript",
|
||||
"cpu", "os", "libc",
|
||||
}
|
||||
|
||||
// NPMMember is one member's full packument for a package; Doc is nil when the
|
||||
// member does not have the package. Tarball URLs under one of Bases are
|
||||
// rewritten to go via the virtual; with no Bases (a local) they are relative.
|
||||
type NPMMember struct {
|
||||
RemoteName string
|
||||
Bases []string
|
||||
Doc []byte
|
||||
}
|
||||
|
||||
// NPMPackument is a merged packument in full and install-v1 form.
|
||||
type NPMPackument struct {
|
||||
Full, Abbreviated []byte
|
||||
}
|
||||
|
||||
func (p *NPMPackument) index() []byte { return p.Full }
|
||||
func (p *NPMPackument) files() map[string][]byte {
|
||||
return map[string][]byte{npmAbbreviatedFile: p.Abbreviated}
|
||||
}
|
||||
|
||||
// MergeNPM unions versions across members (first member wins a duplicate),
|
||||
// keeps the first member's value for each dist-tag, drops tags naming versions
|
||||
// outside the merged set and points every owned tarball at
|
||||
// tarballBase + <member>/<path>. ErrNotFound means no member has the package.
|
||||
func MergeNPM(members []NPMMember, tarballBase string) (*NPMPackument, error) {
|
||||
var base map[string]json.RawMessage
|
||||
versions := map[string]map[string]json.RawMessage{}
|
||||
tags := map[string]string{}
|
||||
times := map[string]json.RawMessage{}
|
||||
var modified string
|
||||
for _, m := range members {
|
||||
if m.Doc == nil {
|
||||
continue
|
||||
}
|
||||
var doc map[string]json.RawMessage
|
||||
var vs map[string]map[string]json.RawMessage
|
||||
var dt map[string]string
|
||||
var tm map[string]json.RawMessage
|
||||
if err := json.Unmarshal(m.Doc, &doc); err != nil {
|
||||
return nil, fmt.Errorf("member %q packument: %w", m.RemoteName, err)
|
||||
}
|
||||
for field, dst := range map[string]any{"versions": &vs, "dist-tags": &dt, "time": &tm} {
|
||||
if raw, ok := doc[field]; ok {
|
||||
if err := json.Unmarshal(raw, dst); err != nil {
|
||||
return nil, fmt.Errorf("member %q %s: %w", m.RemoteName, field, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if base == nil {
|
||||
base = doc
|
||||
}
|
||||
for v, man := range vs {
|
||||
if _, dup := versions[v]; dup || man == nil {
|
||||
continue
|
||||
}
|
||||
if err := rewriteNPMTarball(man, m, tarballBase); err != nil {
|
||||
return nil, fmt.Errorf("member %q %s: %w", m.RemoteName, v, err)
|
||||
}
|
||||
versions[v] = man
|
||||
}
|
||||
for tag, v := range dt {
|
||||
if _, ok := tags[tag]; !ok {
|
||||
tags[tag] = v
|
||||
}
|
||||
}
|
||||
for k, v := range tm {
|
||||
if k == "modified" {
|
||||
var s string
|
||||
if json.Unmarshal(v, &s) == nil && s > modified {
|
||||
modified = s
|
||||
}
|
||||
continue
|
||||
}
|
||||
if _, ok := times[k]; !ok {
|
||||
times[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
if base == nil {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
for tag, v := range tags {
|
||||
if _, ok := versions[v]; !ok {
|
||||
delete(tags, tag)
|
||||
}
|
||||
}
|
||||
if modified != "" {
|
||||
times["modified"], _ = json.Marshal(modified)
|
||||
}
|
||||
|
||||
full := map[string]any{}
|
||||
for k, v := range base {
|
||||
full[k] = v
|
||||
}
|
||||
full["versions"], full["dist-tags"] = versions, tags
|
||||
delete(full, "time")
|
||||
if len(times) > 0 {
|
||||
full["time"] = times
|
||||
}
|
||||
|
||||
abbrevVersions := make(map[string]map[string]json.RawMessage, len(versions))
|
||||
for v, man := range versions {
|
||||
a := map[string]json.RawMessage{}
|
||||
for _, f := range npmAbbreviatedFields {
|
||||
if raw, ok := man[f]; ok {
|
||||
a[f] = raw
|
||||
}
|
||||
}
|
||||
abbrevVersions[v] = a
|
||||
}
|
||||
abbrev := map[string]any{"name": base["name"], "dist-tags": tags, "versions": abbrevVersions}
|
||||
if modified != "" {
|
||||
abbrev["modified"] = modified
|
||||
}
|
||||
|
||||
fullJSON, err := json.Marshal(full)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
abbrevJSON, err := json.Marshal(abbrev)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &NPMPackument{Full: fullJSON, Abbreviated: abbrevJSON}, nil
|
||||
}
|
||||
|
||||
// rewriteNPMTarball leaves a tarball outside the member's bases untouched.
|
||||
func rewriteNPMTarball(man map[string]json.RawMessage, m NPMMember, tarballBase string) error {
|
||||
raw, ok := man["dist"]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var dist map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &dist); err != nil {
|
||||
return fmt.Errorf("dist: %w", err)
|
||||
}
|
||||
var tarball string
|
||||
if err := json.Unmarshal(dist["tarball"], &tarball); err != nil {
|
||||
return nil
|
||||
}
|
||||
rel, ok := npmMemberPath(tarball, m.Bases)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
dist["tarball"], _ = json.Marshal(tarballBase + url.PathEscape(m.RemoteName) + "/" + rel)
|
||||
man["dist"], _ = json.Marshal(dist)
|
||||
return nil
|
||||
}
|
||||
|
||||
func npmMemberPath(tarball string, bases []string) (string, bool) {
|
||||
if len(bases) == 0 {
|
||||
return tarball, tarball != "" && !strings.Contains(tarball, "://") && !strings.HasPrefix(tarball, "/")
|
||||
}
|
||||
for _, b := range bases {
|
||||
if rel, ok := strings.CutPrefix(tarball, strings.TrimRight(b, "/")+"/"); ok && rel != "" {
|
||||
return rel, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// FetchNPM serves the merged packument for the package named by path, full or
|
||||
// abbreviated (install-v1).
|
||||
func (e *Engine) FetchNPM(ctx context.Context, virt models.Virtual, path, proxyBaseURL string, abbreviated bool) ([]byte, string, error) {
|
||||
pkg, err := url.PathUnescape(strings.TrimSuffix(path, "/"))
|
||||
if err != nil || !npmVirtualNameRe.MatchString(pkg) {
|
||||
return nil, "", ErrNotFound
|
||||
}
|
||||
prefix := "npm\x00" + virt.Name + "\x00"
|
||||
tarballBase := strings.TrimRight(proxyBaseURL, "/") + "/api/v1/virtual/" + url.PathEscape(virt.Name) + "/"
|
||||
repo, err := e.merged(ctx, prefix+pkg+"\x00"+proxyBaseURL, func(ctx context.Context) (mergedRepo, error) {
|
||||
members, err := fetchMembers(virt, func(name string) (*NPMMember, error) { return e.npmMember(ctx, name, pkg) })
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p, err := MergeNPM(members, tarballBase)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
e.evictOldest(prefix, maxNPMPackuments)
|
||||
if abbreviated {
|
||||
return repo.files()[npmAbbreviatedFile], NPMAbbreviatedType, nil
|
||||
}
|
||||
return repo.index(), "application/json", nil
|
||||
}
|
||||
|
||||
// fetchNPMMember returns a member with a nil Doc when it lacks the package.
|
||||
func (e *Engine) fetchNPMMember(ctx context.Context, name, pkg string) (*NPMMember, error) {
|
||||
remote, err := e.getRemote(ctx, name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("remote %q: %w", name, err)
|
||||
}
|
||||
m := &NPMMember{RemoteName: name}
|
||||
if remote.RepoType == models.RepoTypeLocal {
|
||||
if e.NPMLocal == nil {
|
||||
return nil, errors.New("local npm packuments are not wired")
|
||||
}
|
||||
m.Doc, err = e.NPMLocal(ctx, name, pkg)
|
||||
} else {
|
||||
m.Bases = remote.UpstreamPool()
|
||||
m.Doc, err = e.fetchMemberPath(ctx, *remote, strings.Replace(pkg, "/", "%2f", 1))
|
||||
}
|
||||
var pe *proxy.ProxyError
|
||||
if errors.Is(err, ErrNotFound) || errors.As(err, &pe) && pe.Status == http.StatusNotFound {
|
||||
return &NPMMember{RemoteName: name}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// evictOldest drops the oldest merges under prefix beyond keep.
|
||||
func (e *Engine) evictOldest(prefix string, keep int) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
var keys []string
|
||||
for k := range e.gens {
|
||||
if strings.HasPrefix(k, prefix) {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
if len(keys) <= keep {
|
||||
return
|
||||
}
|
||||
slices.SortFunc(keys, func(a, b string) int { return e.gens[b].at.Compare(e.gens[a].at) })
|
||||
for _, k := range keys[keep:] {
|
||||
delete(e.gens, k)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package virtual
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/pkg/models"
|
||||
)
|
||||
|
||||
type npmDoc struct {
|
||||
Name string `json:"name"`
|
||||
Readme string `json:"readme,omitempty"`
|
||||
Modified string `json:"modified,omitempty"`
|
||||
DistTags map[string]string `json:"dist-tags"`
|
||||
Versions map[string]map[string]any `json:"versions"`
|
||||
Time map[string]string `json:"time,omitempty"`
|
||||
}
|
||||
|
||||
func npmVersion(name, v, tarball string) map[string]any {
|
||||
return map[string]any{"name": name, "version": v, "readme": "long", "scripts": map[string]string{"test": "x"},
|
||||
"dependencies": map[string]string{"dep": "^1"}, "dist": map[string]any{"tarball": tarball, "shasum": "abc"}}
|
||||
}
|
||||
|
||||
func mustJSON(t *testing.T, v any) []byte {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func decodeNPM(t *testing.T, b []byte) npmDoc {
|
||||
t.Helper()
|
||||
var d npmDoc
|
||||
if err := json.Unmarshal(b, &d); err != nil {
|
||||
t.Fatalf("decode %s: %v", b, err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func tarballOf(d npmDoc, v string) string {
|
||||
dist, _ := d.Versions[v]["dist"].(map[string]any)
|
||||
s, _ := dist["tarball"].(string)
|
||||
return s
|
||||
}
|
||||
|
||||
func npmMembersFixture(t *testing.T) []NPMMember {
|
||||
local := npmDoc{Name: "pkg", DistTags: map[string]string{"latest": "1.0.0", "beta": "9.9.9"},
|
||||
Versions: map[string]map[string]any{"1.0.0": npmVersion("pkg", "1.0.0", "pkg/-/pkg-1.0.0.tgz")}}
|
||||
remote := npmDoc{Name: "pkg", Readme: "remote readme",
|
||||
DistTags: map[string]string{"latest": "2.0.0", "next": "2.0.0", "beta": "1.0.0"},
|
||||
Versions: map[string]map[string]any{
|
||||
"1.0.0": npmVersion("pkg", "1.0.0", "https://registry.npmjs.org/pkg/-/pkg-1.0.0.tgz"),
|
||||
"2.0.0": npmVersion("pkg", "2.0.0", "https://registry.npmjs.org/pkg/-/pkg-2.0.0.tgz"),
|
||||
"3.0.0": npmVersion("pkg", "3.0.0", "https://cdn.example/pkg-3.0.0.tgz"),
|
||||
},
|
||||
Time: map[string]string{"modified": "2024-01-01T00:00:00.000Z", "2.0.0": "2023-01-01T00:00:00.000Z"}}
|
||||
return []NPMMember{
|
||||
{RemoteName: "internal", Doc: mustJSON(t, local)},
|
||||
{RemoteName: "absent"},
|
||||
{RemoteName: "npmjs", Bases: []string{"https://registry.npmjs.org/"}, Doc: mustJSON(t, remote)},
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeNPMUnionsVersionsMemberOrderWins(t *testing.T) {
|
||||
p, err := MergeNPM(npmMembersFixture(t), "http://aa/api/v1/virtual/npm/")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := decodeNPM(t, p.Full)
|
||||
if len(d.Versions) != 3 {
|
||||
t.Fatalf("want 3 merged versions, got %v", d.Versions)
|
||||
}
|
||||
for v, want := range map[string]string{
|
||||
"1.0.0": "http://aa/api/v1/virtual/npm/internal/pkg/-/pkg-1.0.0.tgz",
|
||||
"2.0.0": "http://aa/api/v1/virtual/npm/npmjs/pkg/-/pkg-2.0.0.tgz",
|
||||
"3.0.0": "https://cdn.example/pkg-3.0.0.tgz",
|
||||
} {
|
||||
if got := tarballOf(d, v); got != want {
|
||||
t.Errorf("%s tarball = %q, want %q", v, got, want)
|
||||
}
|
||||
}
|
||||
if d.Readme != "" {
|
||||
t.Errorf("top-level fields come from the first member with the package, got readme %q", d.Readme)
|
||||
}
|
||||
if d.Time["modified"] != "2024-01-01T00:00:00.000Z" || d.Time["2.0.0"] == "" {
|
||||
t.Errorf("time not merged: %v", d.Time)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeNPMDistTags(t *testing.T) {
|
||||
members := npmMembersFixture(t)
|
||||
members[0].Doc = mustJSON(t, npmDoc{Name: "pkg", DistTags: map[string]string{"latest": "1.0.0", "gone": "0.0.1"},
|
||||
Versions: map[string]map[string]any{"1.0.0": npmVersion("pkg", "1.0.0", "pkg/-/pkg-1.0.0.tgz")}})
|
||||
p, err := MergeNPM(members, "http://aa/v/")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := decodeNPM(t, p.Full).DistTags
|
||||
want := map[string]string{"latest": "1.0.0", "next": "2.0.0", "beta": "1.0.0"}
|
||||
if fmt.Sprint(got) != fmt.Sprint(want) {
|
||||
t.Fatalf("dist-tags = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeNPMDropsFirstTagNamingMissingVersion(t *testing.T) {
|
||||
// internal claims beta=9.9.9 first; it wins over npmjs's beta, and is then
|
||||
// dropped because no member has 9.9.9.
|
||||
p, err := MergeNPM(npmMembersFixture(t), "http://aa/v/")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tag, ok := decodeNPM(t, p.Full).DistTags["beta"]; ok {
|
||||
t.Fatalf("beta should be dropped, got %q", tag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeNPMAbbreviated(t *testing.T) {
|
||||
p, err := MergeNPM(npmMembersFixture(t), "http://aa/v/")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var a map[string]any
|
||||
if err := json.Unmarshal(p.Abbreviated, &a); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a["modified"] != "2024-01-01T00:00:00.000Z" || a["name"] != "pkg" || a["readme"] != nil || a["time"] != nil {
|
||||
t.Fatalf("bad abbreviated top level: %v", a)
|
||||
}
|
||||
v := a["versions"].(map[string]any)["2.0.0"].(map[string]any)
|
||||
if v["readme"] != nil || v["scripts"] != nil {
|
||||
t.Errorf("abbreviated version keeps non-install fields: %v", v)
|
||||
}
|
||||
if v["dependencies"] == nil || v["dist"].(map[string]any)["tarball"] != "http://aa/v/npmjs/pkg/-/pkg-2.0.0.tgz" {
|
||||
t.Errorf("abbreviated version lost install fields: %v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeNPMNoMemberHasPackage(t *testing.T) {
|
||||
if _, err := MergeNPM([]NPMMember{{RemoteName: "a"}, {RemoteName: "b"}}, "http://aa/v/"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("want ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNPMMemberPath(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
tarball string
|
||||
bases []string
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{"@s/n/-/n-1.0.0.tgz", nil, "@s/n/-/n-1.0.0.tgz", true},
|
||||
{"https://evil.example/x.tgz", nil, "", false},
|
||||
{"/abs/x.tgz", nil, "", false},
|
||||
{"https://r.example/@s/n/-/n-1.0.0.tgz", []string{"https://m.example", "https://r.example"}, "@s/n/-/n-1.0.0.tgz", true},
|
||||
{"https://r.example.evil/x.tgz", []string{"https://r.example"}, "", false},
|
||||
} {
|
||||
got, ok := npmMemberPath(c.tarball, c.bases)
|
||||
if ok != c.ok || ok && got != c.want {
|
||||
t.Errorf("npmMemberPath(%q, %v) = %q,%v want %q,%v", c.tarball, c.bases, got, ok, c.want, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func npmEngine(fetch func(name, pkg string) (*NPMMember, error)) *Engine {
|
||||
return &Engine{
|
||||
mergeTTL: time.Minute,
|
||||
getRemote: func(_ context.Context, name string) (*models.Remote, error) {
|
||||
return &models.Remote{Name: name, RepoType: models.RepoTypeRemote}, nil
|
||||
},
|
||||
npmMember: func(_ context.Context, name, pkg string) (*NPMMember, error) { return fetch(name, pkg) },
|
||||
}
|
||||
}
|
||||
|
||||
func npmVirt(members ...string) models.Virtual {
|
||||
return models.Virtual{Name: "npm", PackageType: models.PackageNPM, Members: members}
|
||||
}
|
||||
|
||||
func TestFetchNPMScopedAndAbbreviated(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
var asked []string
|
||||
e := npmEngine(func(name, pkg string) (*NPMMember, error) {
|
||||
mu.Lock()
|
||||
asked = append(asked, pkg)
|
||||
mu.Unlock()
|
||||
if name == "b" {
|
||||
return &NPMMember{RemoteName: name}, nil
|
||||
}
|
||||
return &NPMMember{RemoteName: name, Doc: mustJSON(t, npmDoc{Name: pkg, DistTags: map[string]string{"latest": "1.0.0"},
|
||||
Versions: map[string]map[string]any{"1.0.0": npmVersion(pkg, "1.0.0", pkg+"/-/n-1.0.0.tgz")}})}, nil
|
||||
})
|
||||
body, ct, err := e.FetchNPM(context.Background(), npmVirt("a", "b"), "@s%2fn", "http://host", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ct != NPMAbbreviatedType || !strings.Contains(string(body), `"tarball":"http://host/api/v1/virtual/npm/a/@s/n/-/n-1.0.0.tgz"`) {
|
||||
t.Fatalf("ct %q body %s", ct, body)
|
||||
}
|
||||
if asked[0] != "@s/n" {
|
||||
t.Fatalf("member asked for %q, want decoded scoped name", asked[0])
|
||||
}
|
||||
full, ct, err := e.FetchNPM(context.Background(), npmVirt("a", "b"), "@s%2fn", "http://host", false)
|
||||
if err != nil || ct != "application/json" || string(full) == string(body) {
|
||||
t.Fatalf("full packument: ct %q err %v", ct, err)
|
||||
}
|
||||
if len(asked) != 2 {
|
||||
t.Fatalf("second fetch should be served from the merge cache, members asked %d times", len(asked))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchNPMFailsClosed(t *testing.T) {
|
||||
e := npmEngine(func(name, pkg string) (*NPMMember, error) {
|
||||
if name == "b" {
|
||||
return nil, errors.New("upstream down")
|
||||
}
|
||||
return &NPMMember{RemoteName: name, Doc: []byte(`{"name":"x","versions":{}}`)}, nil
|
||||
})
|
||||
_, _, err := e.FetchNPM(context.Background(), npmVirt("a", "b"), "x", "http://host", false)
|
||||
if err == nil || errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("want upstream error with a member down, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchNPMNotFound(t *testing.T) {
|
||||
e := npmEngine(func(name, _ string) (*NPMMember, error) { return &NPMMember{RemoteName: name}, nil })
|
||||
for _, path := range []string{"missing", "-/npm/v1/security/advisories/bulk", "a/../b", "@s"} {
|
||||
if _, _, err := e.FetchNPM(context.Background(), npmVirt("a"), path, "http://host", false); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("%q: want ErrNotFound, got %v", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchNPMMemberLocalNotFound(t *testing.T) {
|
||||
e := &Engine{getRemote: func(_ context.Context, name string) (*models.Remote, error) {
|
||||
return &models.Remote{Name: name, RepoType: models.RepoTypeLocal}, nil
|
||||
}}
|
||||
e.NPMLocal = func(_ context.Context, _, pkg string) ([]byte, error) {
|
||||
if pkg == "boom" {
|
||||
return nil, errors.New("db down")
|
||||
}
|
||||
return nil, fmt.Errorf("wrapped: %w", ErrNotFound)
|
||||
}
|
||||
m, err := e.fetchNPMMember(context.Background(), "internal", "nope")
|
||||
if err != nil || m.Doc != nil {
|
||||
t.Fatalf("missing package must be an empty member, got %v %v", m, err)
|
||||
}
|
||||
if _, err := e.fetchNPMMember(context.Background(), "internal", "boom"); err == nil {
|
||||
t.Fatal("local read errors must surface")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemberRedirectNPM(t *testing.T) {
|
||||
e := npmEngine(nil)
|
||||
virt := npmVirt("internal", "npmjs")
|
||||
loc, ok, err := e.MemberRedirect(context.Background(), virt, "npmjs/@s/n/-/n-1.0.0.tgz", "", "http://host")
|
||||
if err != nil || !ok || loc != "http://host/api/v1/remote/npmjs/@s/n/-/n-1.0.0.tgz" {
|
||||
t.Fatalf("got %q %v %v", loc, ok, err)
|
||||
}
|
||||
if _, ok, _ := e.MemberRedirect(context.Background(), virt, "@s/n", "", "http://host"); ok {
|
||||
t.Fatal("scoped packument path must not redirect")
|
||||
}
|
||||
if _, _, err := e.MemberRedirect(context.Background(), virt, "npmjs/%2e%2e/x.tgz", "", "http://host"); !errors.Is(err, ErrBadPath) {
|
||||
t.Fatalf("want ErrBadPath, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvictOldestBoundsNPMMerges(t *testing.T) {
|
||||
e := npmEngine(func(name, pkg string) (*NPMMember, error) {
|
||||
return &NPMMember{RemoteName: name, Doc: mustJSON(t, npmDoc{Name: pkg, Versions: map[string]map[string]any{}})}, nil
|
||||
})
|
||||
for i := range maxNPMPackuments + 5 {
|
||||
if _, _, err := e.FetchNPM(context.Background(), npmVirt("a"), fmt.Sprintf("p%d", i), "http://host", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if len(e.gens) != maxNPMPackuments {
|
||||
t.Fatalf("cached merges = %d, want %d", len(e.gens), maxNPMPackuments)
|
||||
}
|
||||
if _, ok := e.gens["npm\x00npm\x00p0\x00http://host"]; ok {
|
||||
t.Fatal("oldest merge should be evicted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE IF NOT EXISTS puppet_metadata (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
repo_name TEXT NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
content_hash TEXT NOT NULL,
|
||||
slug TEXT NOT NULL,
|
||||
version TEXT NOT NULL,
|
||||
metadata_json TEXT NOT NULL,
|
||||
file_size BIGINT NOT NULL DEFAULT 0,
|
||||
file_md5 TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(repo_name, file_path)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_puppet_metadata_repo_slug ON puppet_metadata(repo_name, slug);
|
||||
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE IF NOT EXISTS helm_metadata (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
repo_name TEXT NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
content_hash TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
version TEXT NOT NULL,
|
||||
chart_yaml TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(repo_name, file_path)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_helm_metadata_repo ON helm_metadata(repo_name);
|
||||
@@ -4,42 +4,10 @@ import "fmt"
|
||||
|
||||
type PackageType string
|
||||
|
||||
const (
|
||||
PackageGeneric PackageType = "generic"
|
||||
PackageDocker PackageType = "docker"
|
||||
PackageHelm PackageType = "helm"
|
||||
PackagePyPI PackageType = "pypi"
|
||||
PackageNPM PackageType = "npm"
|
||||
PackageRPM PackageType = "rpm"
|
||||
PackageDeb PackageType = "deb"
|
||||
PackageAlpine PackageType = "alpine"
|
||||
PackagePuppet PackageType = "puppet"
|
||||
PackageTerraform PackageType = "terraform"
|
||||
PackageGoProxy PackageType = "goproxy"
|
||||
PackageCargo PackageType = "cargo"
|
||||
PackageNuGet PackageType = "nuget"
|
||||
PackageGitHubRPM PackageType = "github_rpm"
|
||||
PackageGitHubDeb PackageType = "github_deb"
|
||||
PackageGitHubAlpine PackageType = "github_alpine"
|
||||
)
|
||||
var validPackageTypes = map[PackageType]bool{}
|
||||
|
||||
var validPackageTypes = map[PackageType]bool{
|
||||
PackageGeneric: true,
|
||||
PackageDocker: true,
|
||||
PackageHelm: true,
|
||||
PackagePyPI: true,
|
||||
PackageNPM: true,
|
||||
PackageRPM: true,
|
||||
PackageDeb: true,
|
||||
PackageAlpine: true,
|
||||
PackagePuppet: true,
|
||||
PackageTerraform: true,
|
||||
PackageGoProxy: true,
|
||||
PackageCargo: true,
|
||||
PackageNuGet: true,
|
||||
PackageGitHubRPM: true,
|
||||
PackageGitHubDeb: true,
|
||||
PackageGitHubAlpine: true,
|
||||
func registerPackageType(p PackageType) {
|
||||
validPackageTypes[p] = true
|
||||
}
|
||||
|
||||
func (p PackageType) Valid() bool {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package models
|
||||
|
||||
const PackageAlpine PackageType = "alpine"
|
||||
|
||||
func init() { registerPackageType(PackageAlpine) }
|
||||
@@ -0,0 +1,5 @@
|
||||
package models
|
||||
|
||||
const PackageCargo PackageType = "cargo"
|
||||
|
||||
func init() { registerPackageType(PackageCargo) }
|
||||
@@ -0,0 +1,5 @@
|
||||
package models
|
||||
|
||||
const PackageDeb PackageType = "deb"
|
||||
|
||||
func init() { registerPackageType(PackageDeb) }
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user