Compare commits

..

2 Commits

Author SHA1 Message Date
unkinben bd1faeee9b feat: add check_mutable_updates flag for conditional upstream revalidation
When check_mutable_updates: true is set on a remote, expired user-defined
mutable files are revalidated before re-downloading:

- On expiry a conditional HEAD is sent with If-None-Match / If-Modified-Since
- 304 Not Modified: TTL is refreshed in Redis, S3 cache is untouched
- 200 / no conditional support: cache is invalidated and file re-downloaded
- Network error: safe fallback — assume changed, re-download

ETag and Last-Modified from upstream responses are stored in Redis under
mutable:meta:<remote>:<hash> (no expiry, cleaned up on re-download or
cache flush). The flag only applies to user-configured mutable_patterns;
built-in package-type defaults (APKINDEX, repomd.xml, Docker manifests)
are always re-fetched unconditionally.

cache/flush also clears mutable:meta:* keys alongside index:* keys.
2026-04-27 01:00:00 +10:00
unkinben b50abe304e chore: track remotes.yaml as a documented example config
Remove remotes.yaml from .gitignore and add header comments explaining
the immutable_patterns/mutable_patterns/cache keys. Marks the file
clearly as an example to copy and adapt; warns against committing
real credentials.
2026-04-27 00:40:55 +10:00
7 changed files with 210 additions and 787 deletions
+110 -280
View File
@@ -6,14 +6,10 @@ A generic FastAPI-based artifact caching system that downloads and stores files
- **Generic Remote Support**: Works with any HTTP-based file server (GitHub, Gitea, HashiCorp, custom servers) - **Generic Remote Support**: Works with any HTTP-based file server (GitHub, Gitea, HashiCorp, custom servers)
- **Configuration-Based**: YAML configuration for remotes, patterns, and access control - **Configuration-Based**: YAML configuration for remotes, patterns, and access control
- **Direct URL API**: Access cached files via clean URLs like `/api/v1/remote/github/owner/repo/path/file.tar.gz` - **Direct URL API**: Access cached files via clean URLs like `/api/github/owner/repo/path/file.tar.gz`
- **Immutable/Mutable Pattern Model**: Per-remote regex patterns distinguish forever-cached artifacts from TTL-expiring metadata - **Pattern Filtering**: Regex-based inclusion patterns for security and organization
- **Smart Caching**: Automatic download and cache on first access, serve from cache afterward - **Smart Caching**: Automatic download and cache on first access, serve from cache afterward
- **Conditional Revalidation**: Optional `check_mutable_updates` flag — sends `If-None-Match`/`If-Modified-Since` on expiry; skips re-download on 304
- **Stale-on-Upstream-Error**: Expired mutable files are kept and their TTL refreshed when the backend cannot be reached, so cached data remains available during upstream outages
- **S3 Storage**: MinIO/S3 backend with predictable paths - **S3 Storage**: MinIO/S3 backend with predictable paths
- **Docker Registry Proxy**: Full Docker Registry HTTP API v2 for transparent container image caching
- **npm Package Proxy**: Caching proxy for the npm registry with metadata URL rewriting so tarballs also pass through cache
- **Content-Type Detection**: Automatic MIME type detection for downloads - **Content-Type Detection**: Automatic MIME type detection for downloads
## Architecture ## Architecture
@@ -75,18 +71,15 @@ The system uses `remotes.yaml` to define remote repositories and access patterns
remotes: remotes:
remote-name: remote-name:
base_url: "https://example.com" # Base URL for the remote base_url: "https://example.com" # Base URL for the remote
type: "remote" # "remote" or "local" type: "remote" # Type: "remote" or "local"
package: "generic" # "generic", "alpine", "rpm", or "docker" package: "generic" # Package type: "generic", "alpine", "rpm"
description: "Human readable description" description: "Human readable description"
immutable_patterns: # Files cached forever (release binaries, versioned tags) include_patterns: # Regex patterns for allowed files
- "pattern1" - "pattern1"
- "pattern2" - "pattern2"
mutable_patterns: # Files that expire after mutable_ttl (optional) cache: # Cache configuration (optional)
- "pattern3" file_ttl: 0 # File cache TTL (0 = indefinite)
check_mutable_updates: false # Enable conditional HEAD before re-fetching (optional) index_ttl: 300 # Index file TTL in seconds
cache:
immutable_ttl: 0 # TTL for immutable files (0 = indefinitely)
mutable_ttl: 3600 # TTL in seconds for mutable files
``` ```
### Remote Types ### Remote Types
@@ -101,30 +94,30 @@ remotes:
type: "remote" type: "remote"
package: "generic" package: "generic"
description: "GitHub releases and files" description: "GitHub releases and files"
immutable_patterns: include_patterns:
- "gruntwork-io/terragrunt/.*terragrunt_linux_amd64.*" - "gruntwork-io/terragrunt/.*terragrunt_linux_amd64.*"
- "lxc/incus/.*\\.tar\\.gz$" - "lxc/incus/.*\\.tar\\.gz$"
- "prometheus/node_exporter/.*/node_exporter-.*\\.linux-amd64\\.tar\\.gz$" - "prometheus/node_exporter/.*/node_exporter-.*\\.linux-amd64\\.tar\\.gz$"
cache: cache:
immutable_ttl: 0 # Cache files indefinitely file_ttl: 0 # Cache files indefinitely
index_ttl: 0 # No index files for generic remotes
github-archive: hashicorp-releases:
base_url: "https://github.com" base_url: "https://releases.hashicorp.com"
type: "remote" type: "remote"
package: "generic" package: "generic"
description: "GitHub repository archive tarballs" description: "HashiCorp product releases"
immutable_patterns: include_patterns:
- ".*/archive/refs/tags/.*\\.tar\\.gz$" # tag archives never change - "terraform/.*terraform_.*_linux_amd64\\.zip$"
mutable_patterns: - "vault/.*vault_.*_linux_amd64\\.zip$"
- ".*/archive/refs/heads/main\\.tar\\.gz$" # branch archives can change - "consul/.*/consul_.*_linux_amd64\\.zip$"
check_mutable_updates: true # send If-None-Match on expiry; skip re-download on 304
cache: cache:
immutable_ttl: 0 file_ttl: 0
mutable_ttl: 86400 # re-check branch archives after 1 day index_ttl: 0
``` ```
#### Package Repository Remotes #### Package Repository Remotes
For Linux package repositories: For Linux package repositories with index files:
```yaml ```yaml
remotes: remotes:
@@ -133,25 +126,23 @@ remotes:
type: "remote" type: "remote"
package: "alpine" package: "alpine"
description: "Alpine Linux APK package repository" description: "Alpine Linux APK package repository"
immutable_patterns: include_patterns:
- ".*/x86_64/.*\\.apk$" # packages are immutable by content-hash - ".*/x86_64/.*\\.apk$" # Only x86_64 packages
# APKINDEX.tar.gz is a package-type default mutable file — no mutable_patterns needed
cache: cache:
immutable_ttl: 0 file_ttl: 0 # Cache packages indefinitely
mutable_ttl: 7200 # re-fetch APKINDEX.tar.gz after 2 hours index_ttl: 7200 # Cache APKINDEX.tar.gz for 2 hours
almalinux: almalinux:
base_url: "https://mirror.example.com/almalinux" base_url: "http://mirror.aarnet.edu.au/pub/almalinux"
type: "remote" type: "remote"
package: "rpm" package: "rpm"
description: "AlmaLinux RPM package repository" description: "AlmaLinux RPM package repository"
immutable_patterns: include_patterns:
- ".*/x86_64/.*\\.rpm$" - ".*/x86_64/.*\\.rpm$"
- ".*/noarch/.*\\.rpm$" - ".*/noarch/.*\\.rpm$"
# repomd.xml and repodata/* are package-type defaults
cache: cache:
immutable_ttl: 0 file_ttl: 0
mutable_ttl: 7200 index_ttl: 7200 # Cache metadata files for 2 hours
``` ```
#### Local Repositories #### Local Repositories
@@ -164,45 +155,62 @@ remotes:
package: "generic" package: "generic"
description: "Local generic file repository" description: "Local generic file repository"
cache: cache:
immutable_ttl: 0 file_ttl: 0
mutable_ttl: 0 index_ttl: 0
``` ```
### Immutable Patterns ### Include Patterns
`immutable_patterns` are regular expressions that control which files can be accessed. Patterns use Python `re.search`, so they match anywhere in the path unless anchored with `^` or `$`. Only files matching at least one pattern are served; all others return HTTP 403. Include patterns are regular expressions that control which files can be accessed. Patterns use Python `re.search`, so they match anywhere in the path unless anchored with `^` or `$`. Only files matching at least one pattern are served; all others return HTTP 403.
Matched files are cached with `immutable_ttl` (default 0 = forever). Use these for versioned release artifacts that never change once published.
```yaml ```yaml
immutable_patterns: include_patterns:
# Exact project + architecture — most restrictive
- "^gruntwork-io/terragrunt/releases/download/.*/terragrunt_linux_amd64$" - "^gruntwork-io/terragrunt/releases/download/.*/terragrunt_linux_amd64$"
# Any release asset for a project, any version
- "gruntwork-io/terragrunt/.*terragrunt_linux_amd64.*" - "gruntwork-io/terragrunt/.*terragrunt_linux_amd64.*"
# File extension only — allow all files of a given type from any path
- ".*\\.tar\\.gz$" - ".*\\.tar\\.gz$"
- ".*\\.rpm$"
- ".*\\.zip$"
# Architecture subtree — allow everything under x86_64/
- ".*/x86_64/.*"
# Combined: architecture + extension
- ".*/x86_64/.*\\.rpm$" - ".*/x86_64/.*\\.rpm$"
- ".*/noarch/.*\\.rpm$" - ".*/noarch/.*\\.rpm$"
# Docker image names (used with package: docker remotes)
- "^library/nginx" # nginx official images only
- "^rancher/" # all rancher/* images
- "^rancher/rke2-runtime" # specific image
# Repodata directories — allow all metadata for an RPM repo
- ".*/repodata/.*$" - ".*/repodata/.*$"
``` ```
**Security note**: Omitting `immutable_patterns` entirely allows all files from that remote. **Security note**: Omitting `include_patterns` entirely allows all files from that remote. Index files (e.g. `APKINDEX.tar.gz`, `repomd.xml`, tag manifests) always bypass pattern enforcement — they are served unconditionally so clients can discover available packages.
### Mutable Patterns ### Index Patterns
`mutable_patterns` identify files that change over time (index files, branch archives, metadata). Mutable files: Index patterns identify repository metadata files. Index files get special treatment:
- **Always served** regardless of `immutable_patterns` - **Always served** regardless of `include_patterns`
- **Cached with `mutable_ttl`** and re-fetched from upstream when the TTL expires - **Cached with `index_ttl`** instead of `file_ttl`
- **Kept stale** when the upstream backend is unreachable — TTL is refreshed automatically so the cached copy remains available until the backend recovers (see below) - **Automatically refreshed** when the TTL expires — the cached copy is evicted and re-fetched on next request
Built-in defaults per package type (no configuration needed): Built-in defaults per package type:
| Package type | Built-in mutable patterns | | Package type | Built-in index patterns |
|---|---| |---|---|
| `alpine` | `APKINDEX\.tar\.gz$` | | `alpine` | `APKINDEX\.tar\.gz$` |
| `rpm` | `repomd\.xml$`, `repodata/` metadata (xml, sqlite, yaml, asc, txt variants), `Packages\.gz$` | | `rpm` | `repomd\.xml$`, `repodata/` metadata (xml, sqlite, yaml, asc, txt variants), `Packages\.gz$` |
| `docker` | Tag manifests (non-digest refs), `/tags/list` | | `docker` | Tag manifests (non-digest refs), `/tags/list` |
| `generic` | *(none)* | | `generic` | *(none)* |
Use `mutable_patterns` to add extra patterns on top of the defaults. Duplicates are ignored automatically. Use `index_patterns` to add extra patterns on top of the defaults. Duplicates are ignored automatically.
```yaml ```yaml
remotes: remotes:
@@ -210,74 +218,60 @@ remotes:
base_url: "https://charts.example.com" base_url: "https://charts.example.com"
type: "remote" type: "remote"
package: "generic" package: "generic"
immutable_patterns: include_patterns:
- ".*\\.tgz$" - ".*\\.tgz$" # chart archives
mutable_patterns: index_patterns:
- "index\\.yaml$" # Helm repo index - "index\\.yaml$" # Helm repo index — re-fetched on every TTL expiry
cache: cache:
immutable_ttl: 0 file_ttl: 0
mutable_ttl: 600 # re-check the index every 10 minutes index_ttl: 600 # re-check the index every 10 minutes
apt-mirror: apt-mirror:
base_url: "https://apt.example.com" base_url: "https://apt.example.com"
type: "remote" type: "remote"
package: "generic" package: "generic"
immutable_patterns: include_patterns:
- ".*\\.deb$" - ".*\\.deb$"
mutable_patterns: index_patterns:
- "InRelease$" - "InRelease$" # signed APT release file
- "Release$" - "Release$" # unsigned APT release file
- "Packages\\.gz$" - "Packages\\.gz$" # compressed package list
- "Packages\\.xz$" - "Packages\\.xz$"
cache: cache:
immutable_ttl: 0 file_ttl: 0
mutable_ttl: 3600 index_ttl: 3600 # hourly index refresh
```
### Conditional Revalidation (`check_mutable_updates`) almalinux-with-extras:
base_url: "https://mirror.example.com/almalinux"
By default, when a mutable file's TTL expires the cached copy is evicted and the full file is re-downloaded on the next request. Setting `check_mutable_updates: true` on a remote enables a cheaper conditional check first:
1. On TTL expiry, a `HEAD` request is sent to the upstream with `If-None-Match` / `If-Modified-Since` headers (populated from the original download).
2. If the upstream replies **304 Not Modified**, the TTL is refreshed in place — no re-download, no S3 traffic.
3. If the upstream replies **200**, the cached copy is evicted and re-downloaded normally.
This only applies to user-defined `mutable_patterns`. Package-type built-in patterns (APKINDEX, repomd.xml, Docker manifests) are always re-fetched unconditionally.
```yaml
remotes:
github-archive:
base_url: "https://github.com"
type: "remote" type: "remote"
package: "generic" package: "rpm" # inherits repomd.xml + repodata/* defaults
immutable_patterns: include_patterns:
- ".*/archive/refs/tags/.*\\.tar\\.gz$" - ".*/x86_64/.*\\.rpm$"
mutable_patterns: - ".*/noarch/.*\\.rpm$"
- ".*/archive/refs/heads/main\\.tar\\.gz$" index_patterns:
check_mutable_updates: true - "comps\\.xml$" # optional group metadata (adds to rpm defaults)
cache: cache:
immutable_ttl: 0 file_ttl: 0
mutable_ttl: 86400 index_ttl: 7200
``` ```
### Stale-on-Upstream-Error Pattern matching uses `re.search`, so `"index\\.yaml$"` matches `/stable/index.yaml` and `/index.yaml`. Anchor with `^` to restrict to the path root.
When a mutable file's TTL expires and the upstream backend **cannot be reached** (connection refused, DNS failure, timeout), the cached copy is **kept and its TTL refreshed** rather than evicted. This means:
- RPM repodata, Alpine indexes, branch archives, and other mutable files remain available during upstream outages.
- Clients continue to receive the last-known-good copy without errors.
- Once the backend recovers and the refreshed TTL next expires, normal eviction resumes.
This behaviour is automatic and requires no configuration. Only network-level failures trigger it — HTTP error responses (404, 503, etc.) are treated as the backend being reachable and proceed with normal expiry.
### Cache Configuration ### Cache Configuration
Control how long different file types are cached:
```yaml ```yaml
cache: cache:
immutable_ttl: 0 # Immutable files (0 = cache indefinitely, rarely changed) file_ttl: 0 # Regular files (0 = cache indefinitely)
mutable_ttl: 3600 # Mutable files — TTL in seconds before re-fetch is attempted index_ttl: 300 # Index files like APKINDEX.tar.gz (seconds)
``` ```
**Index Files**: Repository metadata files that change frequently:
- Alpine: `APKINDEX.tar.gz`
- RPM: `repomd.xml`, `*-primary.xml.gz`, etc.
- These are automatically detected and use `index_ttl`
### Environment Variables ### Environment Variables
All runtime configuration comes from environment variables: All runtime configuration comes from environment variables:
@@ -357,26 +351,26 @@ data:
type: "remote" type: "remote"
package: "generic" package: "generic"
description: "GitHub releases and files" description: "GitHub releases and files"
immutable_patterns: include_patterns:
- "gruntwork-io/terragrunt/.*terragrunt_linux_amd64.*" - "gruntwork-io/terragrunt/.*terragrunt_linux_amd64.*"
- "lxc/incus/.*\\.tar\\.gz$" - "lxc/incus/.*\\.tar\\.gz$"
- "prometheus/node_exporter/.*/node_exporter-.*\\.linux-amd64\\.tar\\.gz$" - "prometheus/node_exporter/.*/node_exporter-.*\\.linux-amd64\\.tar\\.gz$"
cache: cache:
immutable_ttl: 0 file_ttl: 0
mutable_ttl: 0 index_ttl: 0
hashicorp-releases: hashicorp-releases:
base_url: "https://releases.hashicorp.com" base_url: "https://releases.hashicorp.com"
type: "remote" type: "remote"
package: "generic" package: "generic"
description: "HashiCorp product releases" description: "HashiCorp product releases"
immutable_patterns: include_patterns:
- "terraform/.*terraform_.*_linux_amd64\\.zip$" - "terraform/.*terraform_.*_linux_amd64\\.zip$"
- "vault/.*vault_.*_linux_amd64\\.zip$" - "vault/.*vault_.*_linux_amd64\\.zip$"
- "consul/.*/consul_.*_linux_amd64\\.zip$" - "consul/.*/consul_.*_linux_amd64\\.zip$"
cache: cache:
immutable_ttl: 0 file_ttl: 0
mutable_ttl: 0 index_ttl: 0
``` ```
### 3. Secret for Environment Variables ### 3. Secret for Environment Variables
@@ -784,8 +778,8 @@ remotes:
username: "your-dockerhub-username" username: "your-dockerhub-username"
password: "your-dockerhub-token" # PAT with read scope password: "your-dockerhub-token" # PAT with read scope
cache: cache:
immutable_ttl: 0 file_ttl: 0
mutable_ttl: 300 index_ttl: 300
``` ```
A pull of `nginx:latest` becomes `/v2/dockerhub/library/nginx/manifests/latest` on the artifact API. A pull of `nginx:latest` becomes `/v2/dockerhub/library/nginx/manifests/latest` on the artifact API.
@@ -810,8 +804,8 @@ remotes:
username: "your-github-username" username: "your-github-username"
password: "ghp_your_github_pat" # read:packages scope required password: "ghp_your_github_pat" # read:packages scope required
cache: cache:
immutable_ttl: 0 file_ttl: 0
mutable_ttl: 300 index_ttl: 300
``` ```
A pull of `ghcr.io/rancher/rke2-runtime:v1.30.0-rke2r1` becomes `/v2/ghcr/rancher/rke2-runtime/manifests/v1.30.0-rke2r1`. A pull of `ghcr.io/rancher/rke2-runtime:v1.30.0-rke2r1` becomes `/v2/ghcr/rancher/rke2-runtime/manifests/v1.30.0-rke2r1`.
@@ -850,7 +844,7 @@ Each entry needs a matching remote in `remotes.yaml` using the name from the rew
#### Restricting which images are cached #### Restricting which images are cached
Use `immutable_patterns` on the remote to allow only specific images through the proxy. Requests for images not matching any pattern return HTTP 403 to the node. Use `include_patterns` on the remote to allow only specific images through the proxy. Requests for images not matching any pattern return HTTP 403 to the node.
```yaml ```yaml
remotes: remotes:
@@ -858,17 +852,17 @@ remotes:
base_url: "https://registry-1.docker.io" base_url: "https://registry-1.docker.io"
type: "remote" type: "remote"
package: "docker" package: "docker"
immutable_patterns: include_patterns:
- "^library/nginx" # official nginx only - "^library/nginx" # official nginx only
- "^library/redis" # official redis only - "^library/redis" # official redis only
- "^rancher/" # all rancher images - "^rancher/" # all rancher images
- "^grafana/grafana" # specific image - "^grafana/grafana" # specific image
cache: cache:
immutable_ttl: 0 file_ttl: 0
mutable_ttl: 300 index_ttl: 300
``` ```
Omit `immutable_patterns` to allow all images from that registry. Omit `include_patterns` to allow all images from that registry.
#### TLS configuration #### TLS configuration
@@ -933,167 +927,3 @@ curl -I https://artifacts.example.com/v2/dockerhub/library/nginx/manifests/lates
# Check what's stored in the cache # Check what's stored in the cache
curl https://artifacts.example.com/ | jq '.remotes' curl https://artifacts.example.com/ | jq '.remotes'
``` ```
## Python Package Proxy with uv
The `pypi` package type turns the artifact API into a caching PyPI proxy. Simple index pages (`/simple/{package}/`) are mutable and expire after `mutable_ttl`; package files (wheels, sdists, metadata) are immutable and cached forever. URLs in the simple index HTML are rewritten on the fly to point back through the proxy, so both the index lookup and the file download are served from cache.
### remotes.yaml
```yaml
remotes:
pypi:
base_url: "https://pypi.org"
type: "remote"
package: "pypi"
pypi_files_url: "https://files.pythonhosted.org" # host to rewrite in index HTML
pypi_files_remote: "pypi-files" # our proxy remote to replace it with
check_mutable_updates: true
cache:
immutable_ttl: 0
mutable_ttl: 600 # re-check simple indexes after 10 minutes
pypi-files:
base_url: "https://files.pythonhosted.org"
type: "remote"
package: "generic"
immutable_patterns:
- "packages/.*\\.whl$"
- "packages/.*\\.whl\\.metadata$"
- "packages/.*\\.tar\\.gz$"
- "packages/.*\\.zip$"
- "packages/.*\\.egg$"
cache:
immutable_ttl: 0 # package files are content-addressed — cache forever
# Self-hosted Gitea PyPI registry (index and files share the same base URL)
pypi-gitea:
base_url: "https://gitea.example.com/api/packages/myorg/pypi"
type: "remote"
package: "pypi"
# username: "your-gitea-username"
# password: "your-personal-access-token" # needs package:read scope
pypi_files_url: "https://gitea.example.com/api/packages/myorg/pypi"
pypi_files_remote: "pypi-gitea" # point back to itself — Gitea serves both index and files
check_mutable_updates: true
immutable_patterns:
- "files/.*\\.whl$"
- "files/.*\\.whl\\.metadata$"
- "files/.*\\.tar\\.gz$"
- "files/.*\\.zip$"
- "files/.*\\.egg$"
cache:
immutable_ttl: 0
mutable_ttl: 600
```
### Configuring uv system- or user-wide
uv reads `uv.toml` from two locations outside any project, applied in order from broadest to narrowest scope:
| Scope | Path (Linux/macOS) |
|---|---|
| System | `/etc/uv/uv.toml` |
| User | `~/.config/uv/uv.toml` |
Use these files to route **all** package installs on a machine through the proxy without touching individual projects or their `pyproject.toml`.
**`/etc/uv/uv.toml`** — applies to every user on the host:
```toml
# Replace the default PyPI index with the caching proxy
[[index]]
url = "https://artifacts.example.com/api/v1/remote/pypi/simple"
default = true
# Optionally add a private index (searched alongside the default)
[[index]]
url = "https://artifacts.example.com/api/v1/remote/pypi-gitea/simple"
name = "gitea"
```
**`~/.config/uv/uv.toml`** — same syntax, single-user scope:
```toml
[[index]]
url = "https://artifacts.example.com/api/v1/remote/pypi/simple"
default = true
```
Setting `default = true` replaces uv's built-in PyPI index. The first install of a package fetches it from upstream and populates the cache; every subsequent install — from any machine or fresh environment pointing at the same proxy — is served directly from S3.
### How the rewriting works
When uv requests the simple index for a package, the proxy:
1. Fetches `https://pypi.org/simple/{package}/` (or returns a valid cached copy within `mutable_ttl`)
2. Rewrites every `https://files.pythonhosted.org/...` href to `https://artifacts.example.com/api/v1/remote/pypi-files/...`
3. Returns the rewritten HTML to uv
uv then downloads wheels and `.whl.metadata` files via the rewritten URLs, which also pass through the proxy and are cached as immutable artifacts.
For self-hosted registries like Gitea, both the index and file downloads share the same base URL. Setting `pypi_files_url` and `pypi_files_remote` to the same remote causes file links to be rewritten back through the same proxy entry.
## npm Package Proxy
The `npm` package type turns the artifact API into a caching npm registry proxy. Since the npm registry serves both metadata and tarballs from the same host, a single remote handles everything. Package metadata (e.g. `GET /express`) is mutable and expires after `mutable_ttl`; tarballs (`.tgz`) are immutable and cached forever. `dist.tarball` URLs in metadata JSON are rewritten on the fly to point back through the same remote, so both the metadata lookup and the tarball download are served from cache.
### remotes.yaml
```yaml
remotes:
npm:
base_url: "https://registry.npmjs.org"
type: "remote"
package: "npm"
npm_files_url: "https://registry.npmjs.org" # URL prefix to rewrite in metadata JSON
npm_files_remote: "npm" # rewrite back to this same remote
check_mutable_updates: true
immutable_patterns:
- "\.tgz$" # versioned tarballs are content-addressed — cache forever
mutable_patterns:
- "^(?!.*\.tgz$).*" # everything else (package metadata) expires after mutable_ttl
cache:
immutable_ttl: 0
mutable_ttl: 600 # re-check package metadata after 10 minutes
```
### Configuring npm / yarn / pnpm
**npm** — per-project `.npmrc` or `~/.npmrc`:
```ini
registry=https://artifacts.example.com/api/v1/remote/npm/
```
**yarn**`~/.yarnrc.yml`:
```yaml
npmRegistryServer: "https://artifacts.example.com/api/v1/remote/npm/"
```
**pnpm**`.npmrc`:
```ini
registry=https://artifacts.example.com/api/v1/remote/npm/
```
### How the rewriting works
When a client requests package metadata, the proxy:
1. Fetches `https://registry.npmjs.org/{package}` (or returns a cached copy within `mutable_ttl`)
2. Rewrites every `https://registry.npmjs.org/...` tarball URL to `https://artifacts.example.com/api/v1/remote/npm/...`
3. Returns the rewritten JSON to the client
The client then downloads the tarball via the rewritten URL, which hits the same `npm` remote and is cached as an immutable artifact. Subsequent installs of the same package version are served entirely from S3.
### Mutable vs immutable paths
| Path pattern | Type | Example |
|---|---|---|
| `/{package}` | Mutable (TTL) | `/express` |
| `/@{scope}/{package}` | Mutable (TTL) | `/@babel/core` |
| `/-/all` | Mutable (TTL) | `/-/all` |
| `/{package}/-/{package}-{version}.tgz` | Immutable (forever) | `/express/-/express-4.18.2.tgz` |
| `/@{scope}/{pkg}/-/{pkg}-{ver}.tgz` | Immutable (forever) | `/@babel/core/-/core-7.21.0.tgz` |
+17 -68
View File
@@ -154,7 +154,7 @@ remotes:
- ".*/repodata/.*$" - ".*/repodata/.*$"
cache: cache:
immutable_ttl: 0 # Files cached indefinitely immutable_ttl: 0 # Files cached indefinitely
mutable_ttl: 7200 # Metadata files cached for 2 hours mutable_ttl: 7200 # Metadata files cached for 1 hour
fedora: fedora:
base_url: "https://gsl-syd.mm.fcix.net/fedora/linux" base_url: "https://gsl-syd.mm.fcix.net/fedora/linux"
@@ -171,6 +171,20 @@ remotes:
immutable_ttl: 0 # Files cached indefinitely immutable_ttl: 0 # Files cached indefinitely
mutable_ttl: 300 # Metadata files cached for 5 minutes mutable_ttl: 300 # Metadata files cached for 5 minutes
almalinux-gsl:
base_url: "https://gsl-syd.mm.fcix.net/almalinux"
type: "remote"
package: "rpm"
description: "AlmaLinux RPM package repository (GSL mirror)"
immutable_patterns:
- ".*/x86_64/.*\\.rpm$"
- ".*/noarch/.*\\.rpm$"
- ".*/repodata/.*$"
- ".*\\.rpm$" # Allow all RPM files
cache:
immutable_ttl: 0 # Files cached indefinitely
mutable_ttl: 7200 # Metadata files cached for 2 hours
ghcr: ghcr:
base_url: "https://ghcr.io" base_url: "https://ghcr.io"
type: "remote" type: "remote"
@@ -190,77 +204,12 @@ remotes:
type: "remote" type: "remote"
package: "docker" package: "docker"
description: "Docker Hub registry" description: "Docker Hub registry"
username: "neotheo10"
password: "dckr_pat_smIUKN6m2N1ghbxwRFEcrIByta4"
cache: cache:
immutable_ttl: 0 immutable_ttl: 0
mutable_ttl: 300 mutable_ttl: 300
pypi:
base_url: "https://pypi.org"
type: "remote"
package: "pypi"
description: "Python Package Index — simple repository API"
# pypi_files_url: the upstream host used in simple-index hrefs (default: files.pythonhosted.org)
# pypi_files_remote: our proxy remote that will serve those files (default: pypi-files)
pypi_files_url: "https://files.pythonhosted.org"
pypi_files_remote: "pypi-files"
check_mutable_updates: true
cache:
immutable_ttl: 0
mutable_ttl: 600 # Simple index pages refreshed after 10 minutes
pypi-gitea:
base_url: "https://gitea.example.com/api/packages/myorg/pypi"
type: "remote"
package: "pypi"
description: "Private Gitea PyPI registry"
# username: "your-gitea-username"
# password: "your-personal-access-token" # needs package:read scope
# Files are served from the same Gitea instance — rewrite back to this same remote
pypi_files_url: "https://gitea.example.com/api/packages/myorg/pypi"
pypi_files_remote: "pypi-gitea"
check_mutable_updates: true
immutable_patterns:
- "files/.*\\.whl$"
- "files/.*\\.whl\\.metadata$"
- "files/.*\\.tar\\.gz$"
- "files/.*\\.zip$"
- "files/.*\\.egg$"
cache:
immutable_ttl: 0
mutable_ttl: 600
pypi-files:
base_url: "https://files.pythonhosted.org"
type: "remote"
package: "generic"
description: "Python Package Index — file storage (wheels, sdists)"
immutable_patterns:
- "packages/.*\\.whl$"
- "packages/.*\\.whl\\.metadata$"
- "packages/.*\\.tar\\.gz$"
- "packages/.*\\.zip$"
- "packages/.*\\.egg$"
cache:
immutable_ttl: 0 # Package files are content-addressed — cache forever
npm:
base_url: "https://registry.npmjs.org"
type: "remote"
package: "npm"
description: "npm registry — package metadata with tarball URL rewriting"
# npm_files_url: the upstream host used in metadata tarball hrefs (default: https://registry.npmjs.org)
# npm_files_remote: our proxy remote that will serve those tarballs (default: npm-files)
npm_files_url: "https://registry.npmjs.org"
npm_files_remote: "npm"
check_mutable_updates: true
immutable_patterns:
- \.tgz$
mutable_patterns:
- ^(?!.*\.tgz$).*
cache:
immutable_ttl: 0
mutable_ttl: 600 # Package metadata refreshed after 10 minutes
local-generic: local-generic:
type: "local" type: "local"
package: "generic" package: "generic"
-4
View File
@@ -18,10 +18,6 @@ _PACKAGE_MUTABLE_PATTERNS: dict[str, list[str]] = {
r"/manifests/(?!sha256:)[^/]+$", r"/manifests/(?!sha256:)[^/]+$",
r"/tags/list$", r"/tags/list$",
], ],
"pypi": [
r"simple/", # Per-package and top-level simple index pages
],
"npm": [],
"generic": [], "generic": [],
} }
+76 -121
View File
@@ -1,4 +1,3 @@
import base64
import hashlib import hashlib
import json import json
import logging import logging
@@ -33,10 +32,6 @@ class ArtifactRequest(BaseModel):
include_pattern: str include_pattern: str
class UpstreamUnreachable(Exception):
"""Raised when the upstream backend cannot be contacted (network or timeout error)."""
# Configure logging # Configure logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s") logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -209,11 +204,8 @@ async def cache_single_artifact(url: str, remote_name: str, path: str) -> dict:
remote_config = config.get_remote_config(remote_name) or {} remote_config = config.get_remote_config(remote_name) or {}
is_docker = remote_config.get("package") == "docker" or "/v2/" in url is_docker = remote_config.get("package") == "docker" or "/v2/" in url
# Prepare headers # Prepare headers for Docker registry requests
headers = {} headers = {}
username = remote_config.get("username")
password = remote_config.get("password")
if is_docker: if is_docker:
if "/manifests/" in url: if "/manifests/" in url:
headers["Accept"] = ( headers["Accept"] = (
@@ -224,8 +216,6 @@ async def cache_single_artifact(url: str, remote_name: str, path: str) -> dict:
) )
elif "/blobs/" in url: elif "/blobs/" in url:
headers["Accept"] = "application/octet-stream" headers["Accept"] = "application/octet-stream"
elif username and password:
headers["Authorization"] = "Basic " + base64.b64encode(f"{username}:{password}".encode()).decode()
async with httpx.AsyncClient(follow_redirects=True) as client: async with httpx.AsyncClient(follow_redirects=True) as client:
response = await client.get(url, headers=headers) response = await client.get(url, headers=headers)
@@ -260,129 +250,30 @@ async def cache_single_artifact(url: str, remote_name: str, path: str) -> dict:
return {"url": url, "status": "error", "error": str(e)} return {"url": url, "status": "error", "error": str(e)}
def _basic_auth_header(remote_cfg: dict) -> dict[str, str]: async def check_upstream_changed(remote_url: str, remote_name: str, path: str) -> bool:
username = remote_cfg.get("username") """Conditional HEAD against upstream. Returns False only on a definitive 304."""
password = remote_cfg.get("password")
if username and password:
token = base64.b64encode(f"{username}:{password}".encode()).decode()
return {"Authorization": f"Basic {token}"}
return {}
async def _upstream_reachable(url: str, auth_headers: dict | None = None) -> bool:
"""HEAD with a short timeout. Returns False only on network/timeout errors."""
try:
async with httpx.AsyncClient(follow_redirects=True) as client:
await client.head(url, headers=auth_headers or {}, timeout=10.0)
return True
except (httpx.NetworkError, httpx.TimeoutException):
return False
except Exception:
return True # 4xx/5xx means backend is up
async def check_upstream_changed(remote_url: str, remote_name: str, path: str, auth_headers: dict | None = None) -> bool:
"""Conditional HEAD against upstream. Returns False only on a definitive 304.
Raises UpstreamUnreachable if the backend cannot be contacted."""
meta = cache.get_mutable_meta(remote_name, path) meta = cache.get_mutable_meta(remote_name, path)
if not meta: if not meta:
return True return True
headers = dict(auth_headers or {}) headers = {}
if meta.get("etag"): if meta.get("etag"):
headers["If-None-Match"] = meta["etag"] headers["If-None-Match"] = meta["etag"]
if meta.get("last_modified"): if meta.get("last_modified"):
headers["If-Modified-Since"] = meta["last_modified"] headers["If-Modified-Since"] = meta["last_modified"]
if not (meta.get("etag") or meta.get("last_modified")): if not headers:
return True return True
try: try:
async with httpx.AsyncClient(follow_redirects=True) as client: async with httpx.AsyncClient(follow_redirects=True) as client:
response = await client.head(remote_url, headers=headers) response = await client.head(remote_url, headers=headers)
return response.status_code != 304 return response.status_code != 304
except (httpx.NetworkError, httpx.TimeoutException) as exc: except Exception:
raise UpstreamUnreachable(str(exc)) from exc return True
async def handle_expired_mutable(remote_name: str, path: str, remote_url: str) -> bool:
"""Handle an expired mutable file. Returns True if the cached copy is still valid."""
mutable_ttl = config.get_cache_config(remote_name).get("mutable_ttl", 3600)
remote_cfg = config.get_remote_config(remote_name) or {}
auth = _basic_auth_header(remote_cfg)
check_updates = remote_cfg.get("check_mutable_updates", False)
user_mutable = check_updates and cache.is_mutable_file(path, config.get_user_mutable_patterns(remote_name))
if user_mutable:
try:
changed = await check_upstream_changed(remote_url, remote_name, path, auth)
except UpstreamUnreachable:
cache.mark_index_cached(remote_name, path, mutable_ttl)
logger.warning(f"Mutable STALE (backend unreachable): {remote_name}/{path} - TTL extended ({mutable_ttl}s)")
return True
if not changed:
cache.mark_index_cached(remote_name, path, mutable_ttl)
logger.info(f"Mutable file UNCHANGED: {remote_name}/{path} - TTL refreshed ({mutable_ttl}s)")
return True
logger.info(f"Mutable file CHANGED: {remote_name}/{path} - re-downloading")
else:
if not await _upstream_reachable(remote_url, auth):
cache.mark_index_cached(remote_name, path, mutable_ttl)
logger.warning(f"Mutable STALE (backend unreachable): {remote_name}/{path} - TTL extended ({mutable_ttl}s)")
return True
logger.info(f"Mutable file EXPIRED: {remote_name}/{path} - removing from cache")
cache.cleanup_expired_index(storage, remote_name, path)
return False
def _get_content_type(filename: str) -> str:
if filename.endswith((".tar.gz", ".tgz")):
return "application/gzip"
if filename.endswith(".zip") or filename.endswith(".whl"):
return "application/zip"
if filename.endswith(".exe"):
return "application/x-msdownload"
if filename.endswith(".rpm"):
return "application/x-rpm"
if filename.endswith(".xml"):
return "application/xml"
if filename.endswith((".xml.gz", ".xml.bz2", ".xml.xz")):
return "application/gzip"
return "application/octet-stream"
def _resolve_content(
data: bytes,
path: str,
filename: str,
remote_config: dict,
request: Request,
) -> tuple[bytes, str]:
"""Return (possibly-rewritten data, content_type) for a cached artifact."""
if remote_config.get("package") == "pypi" and "simple/" in path:
files_url = remote_config.get("pypi_files_url", "https://files.pythonhosted.org")
files_remote = remote_config.get("pypi_files_remote", "pypi-files")
proxy_base = str(request.base_url).rstrip("/")
data = data.replace(
files_url.rstrip("/").encode(),
f"{proxy_base}/api/v1/remote/{files_remote}".encode(),
)
return data, "text/html; charset=utf-8"
if remote_config.get("package") == "npm" and not path.endswith(".tgz"):
files_url = remote_config.get("npm_files_url", "https://registry.npmjs.org")
files_remote = remote_config.get("npm_files_remote", "npm-files")
proxy_base = str(request.base_url).rstrip("/")
data = data.replace(
files_url.rstrip("/").encode(),
f"{proxy_base}/api/v1/remote/{files_remote}".encode(),
)
return data, "application/json"
return data, _get_content_type(filename)
@app.get("/api/v1/remote/{remote_name}/{path:path}") @app.get("/api/v1/remote/{remote_name}/{path:path}")
async def get_artifact(request: Request, remote_name: str, path: str): async def get_artifact(remote_name: str, path: str):
# Check if remote is configured # Check if remote is configured
remote_config = config.get_remote_config(remote_name) remote_config = config.get_remote_config(remote_name)
if not remote_config: if not remote_config:
@@ -437,7 +328,22 @@ async def get_artifact(request: Request, remote_name: str, path: str):
if cached_key and is_mutable: if cached_key and is_mutable:
if not cache.is_index_valid(remote_name, path): if not cache.is_index_valid(remote_name, path):
if not await handle_expired_mutable(remote_name, path, remote_url): remote_cfg = config.get_remote_config(remote_name) or {}
check_updates = remote_cfg.get("check_mutable_updates", False)
user_mutable = check_updates and cache.is_mutable_file(path, config.get_user_mutable_patterns(remote_name))
if user_mutable:
changed = await check_upstream_changed(remote_url, remote_name, path)
if not changed:
mutable_ttl = config.get_cache_config(remote_name).get("mutable_ttl", 3600)
cache.mark_index_cached(remote_name, path, mutable_ttl)
logger.info(f"Mutable file UNCHANGED: {remote_name}/{path} - TTL refreshed ({mutable_ttl}s)")
else:
logger.info(f"Mutable file CHANGED: {remote_name}/{path} - re-downloading")
cache.cleanup_expired_index(storage, remote_name, path)
cached_key = None
else:
logger.info(f"Mutable file EXPIRED: {remote_name}/{path} - removing from cache")
cache.cleanup_expired_index(storage, remote_name, path)
cached_key = None cached_key = None
if cached_key: if cached_key:
@@ -445,11 +351,29 @@ async def get_artifact(request: Request, remote_name: str, path: str):
try: try:
artifact_data = storage.download_object(cached_key) artifact_data = storage.download_object(cached_key)
filename = os.path.basename(path) filename = os.path.basename(path)
artifact_data, content_type = _resolve_content(artifact_data, path, filename, remote_config, request)
# Log cache hit
logger.info(f"Cache HIT: {remote_name}/{path} (size: {len(artifact_data)} bytes, key: {cached_key})") logger.info(f"Cache HIT: {remote_name}/{path} (size: {len(artifact_data)} bytes, key: {cached_key})")
# Determine content type based on file extension
content_type = "application/octet-stream"
if filename.endswith(".tar.gz"):
content_type = "application/gzip"
elif filename.endswith(".zip"):
content_type = "application/zip"
elif filename.endswith(".exe"):
content_type = "application/x-msdownload"
elif filename.endswith(".rpm"):
content_type = "application/x-rpm"
elif filename.endswith(".xml"):
content_type = "application/xml"
elif filename.endswith((".xml.gz", ".xml.bz2", ".xml.xz")):
content_type = "application/gzip"
# Record cache hit metrics
metrics.record_cache_hit(remote_name, len(artifact_data)) metrics.record_cache_hit(remote_name, len(artifact_data))
# Record artifact mapping in database if not already recorded
database.record_artifact_mapping(cached_key, remote_name, path, len(artifact_data)) database.record_artifact_mapping(cached_key, remote_name, path, len(artifact_data))
return Response( return Response(
@@ -486,9 +410,25 @@ async def get_artifact(request: Request, remote_name: str, path: str):
cache_key = storage.get_object_key(remote_name, path) cache_key = storage.get_object_key(remote_name, path)
artifact_data = storage.download_object(cache_key) artifact_data = storage.download_object(cache_key)
filename = os.path.basename(path) filename = os.path.basename(path)
artifact_data, content_type = _resolve_content(artifact_data, path, filename, remote_config, request)
content_type = "application/octet-stream"
if filename.endswith(".tar.gz"):
content_type = "application/gzip"
elif filename.endswith(".zip"):
content_type = "application/zip"
elif filename.endswith(".exe"):
content_type = "application/x-msdownload"
elif filename.endswith(".rpm"):
content_type = "application/x-rpm"
elif filename.endswith(".xml"):
content_type = "application/xml"
elif filename.endswith((".xml.gz", ".xml.bz2", ".xml.xz")):
content_type = "application/gzip"
# Record cache miss metrics
metrics.record_cache_miss(remote_name, len(artifact_data)) metrics.record_cache_miss(remote_name, len(artifact_data))
# Record artifact mapping in database
cache_key = storage.get_object_key(remote_name, path) cache_key = storage.get_object_key(remote_name, path)
database.record_artifact_mapping(cache_key, remote_name, path, len(artifact_data)) database.record_artifact_mapping(cache_key, remote_name, path, len(artifact_data))
@@ -541,7 +481,22 @@ async def docker_v2_proxy(request: Request, remote_name: str, path: str):
if cached_key and is_mutable: if cached_key and is_mutable:
if not cache.is_index_valid(remote_name, path): if not cache.is_index_valid(remote_name, path):
if not await handle_expired_mutable(remote_name, path, remote_url): remote_cfg = config.get_remote_config(remote_name) or {}
check_updates = remote_cfg.get("check_mutable_updates", False)
user_mutable = check_updates and cache.is_mutable_file(path, config.get_user_mutable_patterns(remote_name))
if user_mutable:
changed = await check_upstream_changed(remote_url, remote_name, path)
if not changed:
mutable_ttl = config.get_cache_config(remote_name).get("mutable_ttl", 3600)
cache.mark_index_cached(remote_name, path, mutable_ttl)
logger.info(f"Mutable file UNCHANGED: {remote_name}/{path} - TTL refreshed ({mutable_ttl}s)")
else:
logger.info(f"Mutable file CHANGED: {remote_name}/{path} - re-downloading")
cache.cleanup_expired_index(storage, remote_name, path)
cached_key = None
else:
logger.info(f"Mutable file EXPIRED: {remote_name}/{path} - removing from cache")
cache.cleanup_expired_index(storage, remote_name, path)
cached_key = None cached_key = None
if not cached_key: if not cached_key:
-29
View File
@@ -72,35 +72,6 @@ TEST_REMOTES = {
"package": "generic", "package": "generic",
"cache": {"immutable_ttl": 0, "mutable_ttl": 0}, "cache": {"immutable_ttl": 0, "mutable_ttl": 0},
}, },
"pypi-test": {
"base_url": "https://pypi.org",
"type": "remote",
"package": "pypi",
"pypi_files_url": "https://files.pythonhosted.org",
"pypi_files_remote": "pypi-files-test",
"cache": {"immutable_ttl": 0, "mutable_ttl": 600},
},
"pypi-files-test": {
"base_url": "https://files.pythonhosted.org",
"type": "remote",
"package": "generic",
"immutable_patterns": [
"packages/.*\\.whl$",
"packages/.*\\.whl\\.metadata$",
"packages/.*\\.tar\\.gz$",
],
"cache": {"immutable_ttl": 0, "mutable_ttl": 0},
},
"npm-test": {
"base_url": "https://registry.npmjs.org",
"type": "remote",
"package": "npm",
"npm_files_url": "https://registry.npmjs.org",
"npm_files_remote": "npm-test",
"immutable_patterns": [r"\.tgz$"],
"mutable_patterns": [r"^(?!.*\.tgz$).*"],
"cache": {"immutable_ttl": 0, "mutable_ttl": 600},
},
} }
} }
-38
View File
@@ -133,44 +133,6 @@ class TestGetMutablePatterns:
assert r"repomd\.xml$" in patterns assert r"repomd\.xml$" in patterns
assert r"custom-meta\.xml$" in patterns assert r"custom-meta\.xml$" in patterns
def test_npm_has_no_package_defaults(self, make_config):
cfg = make_config({"r": {"type": "remote", "package": "npm", "base_url": "https://x.com"}})
assert cfg.get_mutable_patterns("r") == []
def test_npm_explicit_mutable_pattern_matches_metadata(self, make_config):
import re
cfg = make_config(
{
"r": {
"type": "remote",
"package": "npm",
"base_url": "https://x.com",
"mutable_patterns": [r"^(?!.*\.tgz$).*"],
}
}
)
patterns = cfg.get_mutable_patterns("r")
assert any(re.search(p, "express") for p in patterns)
assert any(re.search(p, "@babel/core") for p in patterns)
def test_npm_explicit_mutable_pattern_excludes_tarballs(self, make_config):
import re
cfg = make_config(
{
"r": {
"type": "remote",
"package": "npm",
"base_url": "https://x.com",
"mutable_patterns": [r"^(?!.*\.tgz$).*"],
}
}
)
patterns = cfg.get_mutable_patterns("r")
assert not any(re.search(p, "express-4.18.2.tgz") for p in patterns)
assert not any(re.search(p, "express/-/express-4.18.2.tgz") for p in patterns)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# get_immutable_patterns # get_immutable_patterns
+6 -246
View File
@@ -248,13 +248,12 @@ class TestDockerProxy:
deps["cache"].is_index_valid.return_value = False # but TTL expired deps["cache"].is_index_valid.return_value = False # but TTL expired
deps["storage"].download_object.return_value = manifest deps["storage"].download_object.return_value = manifest
with patch("artifactapi.main._upstream_reachable", new_callable=AsyncMock, return_value=True): with patch(
with patch( "artifactapi.main.cache_single_artifact",
"artifactapi.main.cache_single_artifact", new_callable=AsyncMock,
new_callable=AsyncMock, return_value={"status": "cached"},
return_value={"status": "cached"}, ) as mock_fetch:
) as mock_fetch: response = client.get("/v2/docker-test/library/nginx/manifests/latest")
response = client.get("/v2/docker-test/library/nginx/manifests/latest")
mock_fetch.assert_called_once() mock_fetch.assert_called_once()
assert response.status_code == 200 assert response.status_code == 200
@@ -453,56 +452,6 @@ class TestGenericArtifactRoute:
assert response.status_code == 502 assert response.status_code == 502
def test_mutable_changed_redownloads_successfully(self, client, patched_deps):
"""When check_mutable_updates=True and upstream says 200, fresh copy is fetched and served."""
deps = patched_deps
deps["storage"].exists.return_value = True
deps["storage"].download_object.return_value = b"fresh metadata"
deps["cache"].is_mutable_file.return_value = True
deps["cache"].is_index_valid.return_value = False
deps["cache"].get_mutable_meta.return_value = {"etag": '"abc"'}
with patch("artifactapi.main.check_upstream_changed", new_callable=AsyncMock, return_value=True):
with patch("artifactapi.main.cache_single_artifact", new_callable=AsyncMock) as mock_cache:
mock_cache.return_value = {"status": "cached", "etag": '"def"', "last_modified": None}
response = client.get("/api/v1/remote/check-mutable-test/metadata.json")
assert response.status_code == 200
mock_cache.assert_called_once()
def test_mutable_backend_unreachable_on_check_updates_keeps_stale(self, client, patched_deps):
"""When check_mutable_updates=True and backend is unreachable, stale copy is kept and TTL refreshed."""
from artifactapi.main import UpstreamUnreachable
deps = patched_deps
deps["storage"].exists.return_value = True
deps["storage"].download_object.return_value = b"stale metadata"
deps["cache"].is_mutable_file.return_value = True
deps["cache"].is_index_valid.return_value = False
deps["cache"].get_mutable_meta.return_value = {"etag": '"abc"'}
with patch("artifactapi.main.check_upstream_changed", side_effect=UpstreamUnreachable("connection refused")):
response = client.get("/api/v1/remote/check-mutable-test/metadata.json")
assert response.status_code == 200
deps["cache"].mark_index_cached.assert_called()
deps["storage"].client.delete_object.assert_not_called()
def test_mutable_backend_unreachable_on_expiry_keeps_stale(self, client, patched_deps):
"""When a regular mutable file expires and backend is unreachable, stale copy is kept and TTL refreshed."""
deps = patched_deps
deps["storage"].exists.return_value = True
deps["storage"].download_object.return_value = b"stale APKINDEX"
deps["cache"].is_mutable_file.return_value = True
deps["cache"].is_index_valid.return_value = False
with patch("artifactapi.main._upstream_reachable", new_callable=AsyncMock, return_value=False):
response = client.get("/api/v1/remote/alpine-test/alpine/v3.18/x86_64/APKINDEX.tar.gz")
assert response.status_code == 200
deps["cache"].mark_index_cached.assert_called()
deps["storage"].client.delete_object.assert_not_called()
def test_mutable_flag_off_skips_conditional_check(self, client, patched_deps): def test_mutable_flag_off_skips_conditional_check(self, client, patched_deps):
"""When check_mutable_updates is not set, expired mutable files are always re-fetched.""" """When check_mutable_updates is not set, expired mutable files are always re-fetched."""
deps = patched_deps deps = patched_deps
@@ -652,192 +601,3 @@ class TestConfigEndpoint:
data = response.json() data = response.json()
assert "remotes" in data assert "remotes" in data
assert "alpine-test" in data["remotes"] assert "alpine-test" in data["remotes"]
# ---------------------------------------------------------------------------
# PyPI remote /api/v1/remote/pypi-test/...
# ---------------------------------------------------------------------------
class TestPyPIRemote:
def test_simple_index_is_mutable(self, client, patched_deps):
"""simple/ paths are detected as mutable (package-type default)."""
deps = patched_deps
html = b"<html><body><a href='https://files.pythonhosted.org/packages/requests-2.31.0.tar.gz'>...</a></body></html>"
deps["storage"].exists.return_value = True
deps["storage"].download_object.return_value = html
deps["cache"].is_mutable_file.return_value = True
deps["cache"].is_index_valid.return_value = True
response = client.get("/api/v1/remote/pypi-test/simple/requests/")
assert response.status_code == 200
deps["cache"].mark_index_cached.assert_not_called()
def test_simple_index_urls_rewritten_to_proxy(self, client, patched_deps):
"""files.pythonhosted.org URLs in a cached simple index are rewritten to our proxy."""
deps = patched_deps
html = b"<html><body><a href='https://files.pythonhosted.org/packages/requests-2.31.0.tar.gz'>...</a></body></html>"
deps["storage"].exists.return_value = True
deps["storage"].download_object.return_value = html
deps["cache"].is_mutable_file.return_value = True
deps["cache"].is_index_valid.return_value = True
response = client.get("/api/v1/remote/pypi-test/simple/requests/")
assert response.status_code == 200
assert b"files.pythonhosted.org" not in response.content
assert b"/api/v1/remote/pypi-files-test/packages/requests-2.31.0.tar.gz" in response.content
def test_simple_index_content_type_is_html(self, client, patched_deps):
deps = patched_deps
deps["storage"].exists.return_value = True
deps["storage"].download_object.return_value = b"<html></html>"
deps["cache"].is_mutable_file.return_value = True
deps["cache"].is_index_valid.return_value = True
response = client.get("/api/v1/remote/pypi-test/simple/requests/")
assert response.status_code == 200
assert "text/html" in response.headers["content-type"]
def test_simple_index_cache_miss_fetches_upstream(self, client, patched_deps):
deps = patched_deps
html = b"<html><body><a href='https://files.pythonhosted.org/packages/p-1.0.whl'>...</a></body></html>"
deps["storage"].exists.return_value = False
deps["storage"].download_object.return_value = html
deps["cache"].is_mutable_file.return_value = True
with patch(
"artifactapi.main.cache_single_artifact",
new_callable=AsyncMock,
return_value={"status": "cached"},
) as mock_fetch:
response = client.get("/api/v1/remote/pypi-test/simple/requests/")
mock_fetch.assert_called_once()
assert response.status_code == 200
assert b"files.pythonhosted.org" not in response.content
def test_wheel_file_immutable_returns_correct_content_type(self, client, patched_deps):
deps = patched_deps
deps["storage"].exists.return_value = True
deps["storage"].download_object.return_value = b"PK wheel bytes"
deps["cache"].is_mutable_file.return_value = False
response = client.get("/api/v1/remote/pypi-files-test/packages/requests-2.31.0-py3-none-any.whl")
assert response.status_code == 200
assert "application/zip" in response.headers["content-type"]
assert response.headers["X-Artifact-Source"] == "cache"
def test_sdist_immutable_returns_correct_content_type(self, client, patched_deps):
deps = patched_deps
deps["storage"].exists.return_value = True
deps["storage"].download_object.return_value = b"tar bytes"
deps["cache"].is_mutable_file.return_value = False
response = client.get("/api/v1/remote/pypi-files-test/packages/requests-2.31.0.tar.gz")
assert response.status_code == 200
assert "application/gzip" in response.headers["content-type"]
def test_blocked_path_on_files_remote_returns_403(self, client, patched_deps):
"""Paths that don't match immutable_patterns on pypi-files-test are blocked."""
response = client.get("/api/v1/remote/pypi-files-test/packages/requests.unknown")
assert response.status_code == 403
# ---------------------------------------------------------------------------
# npm remote /api/v1/remote/npm-test/...
# ---------------------------------------------------------------------------
class TestNpmRemote:
def test_package_metadata_is_mutable(self, client, patched_deps):
"""Top-level package metadata paths are detected as mutable."""
deps = patched_deps
meta = b'{"name":"express","versions":{}}'
deps["storage"].exists.return_value = True
deps["storage"].download_object.return_value = meta
deps["cache"].is_mutable_file.return_value = True
deps["cache"].is_index_valid.return_value = True
response = client.get("/api/v1/remote/npm-test/express")
assert response.status_code == 200
deps["cache"].mark_index_cached.assert_not_called()
def test_metadata_tarball_urls_rewritten_to_proxy(self, client, patched_deps):
"""registry.npmjs.org tarball URLs in metadata JSON are rewritten to our proxy."""
deps = patched_deps
meta = b'{"dist":{"tarball":"https://registry.npmjs.org/express/-/express-4.18.2.tgz"}}'
deps["storage"].exists.return_value = True
deps["storage"].download_object.return_value = meta
deps["cache"].is_mutable_file.return_value = True
deps["cache"].is_index_valid.return_value = True
response = client.get("/api/v1/remote/npm-test/express")
assert response.status_code == 200
assert b"registry.npmjs.org" not in response.content
assert b"/api/v1/remote/npm-test/express/-/express-4.18.2.tgz" in response.content
def test_metadata_content_type_is_json(self, client, patched_deps):
deps = patched_deps
deps["storage"].exists.return_value = True
deps["storage"].download_object.return_value = b'{"name":"express"}'
deps["cache"].is_mutable_file.return_value = True
deps["cache"].is_index_valid.return_value = True
response = client.get("/api/v1/remote/npm-test/express")
assert response.status_code == 200
assert "application/json" in response.headers["content-type"]
def test_scoped_package_metadata_rewritten(self, client, patched_deps):
"""@scope/package metadata URLs are also rewritten back to the same npm-test remote."""
deps = patched_deps
meta = b'{"dist":{"tarball":"https://registry.npmjs.org/@babel/core/-/core-7.21.0.tgz"}}'
deps["storage"].exists.return_value = True
deps["storage"].download_object.return_value = meta
deps["cache"].is_mutable_file.return_value = True
deps["cache"].is_index_valid.return_value = True
response = client.get("/api/v1/remote/npm-test/@babel/core")
assert response.status_code == 200
assert b"registry.npmjs.org" not in response.content
assert b"/api/v1/remote/npm-test/@babel/core/-/core-7.21.0.tgz" in response.content
def test_tarball_not_rewritten(self, client, patched_deps):
"""Tarball requests (.tgz) bypass URL rewriting and return binary."""
deps = patched_deps
deps["storage"].exists.return_value = True
deps["storage"].download_object.return_value = b"\x1f\x8b tgz bytes"
deps["cache"].is_mutable_file.return_value = False
response = client.get("/api/v1/remote/npm-test/express/-/express-4.18.2.tgz")
assert response.status_code == 200
assert "application/gzip" in response.headers["content-type"]
assert response.headers["X-Artifact-Source"] == "cache"
def test_metadata_cache_miss_fetches_upstream(self, client, patched_deps):
deps = patched_deps
meta = b'{"dist":{"tarball":"https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz"}}'
deps["storage"].exists.return_value = False
deps["storage"].download_object.return_value = meta
deps["cache"].is_mutable_file.return_value = True
with patch(
"artifactapi.main.cache_single_artifact",
new_callable=AsyncMock,
return_value={"status": "cached"},
) as mock_fetch:
response = client.get("/api/v1/remote/npm-test/lodash")
mock_fetch.assert_called_once()
assert response.status_code == 200
assert b"registry.npmjs.org" not in response.content
def test_tarball_immutable_allowed_on_npm_remote(self, client, patched_deps):
"""Tarballs (.tgz) match immutable_patterns and are served without rewriting."""
deps = patched_deps
deps["storage"].exists.return_value = True
deps["storage"].download_object.return_value = b"tgz bytes"
deps["cache"].is_mutable_file.return_value = False
response = client.get("/api/v1/remote/npm-test/express/-/express-4.18.2.tgz")
assert response.status_code == 200
assert "application/gzip" in response.headers["content-type"]