Compare commits

..

19 Commits

Author SHA1 Message Date
unkinben b8bc7f8714 Merge pull request 'chore: cleanup the readme' (#23) from benvin/readme-refactor into master
Reviewed-on: #23
2026-04-28 22:00:32 +10:00
unkinben 0c780c1bd1 chore: cleanup the readme
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
2026-04-28 21:57:14 +10:00
unkinben 173b5d8b10 Merge pull request 'refactor: simplify pypi and npm URL rewriting' (#18) from benvin/simplify-remote-url-rewriting into master
Reviewed-on: #18
2026-04-27 22:43:33 +10:00
unkinben 3352a3e886 refactor: simplify pypi and npm URL rewriting — single remote, no redundant config keys
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
- npm: remove npm_files_url/npm_files_remote; rewrite uses base_url and
  remote name directly (same approach as helm)
- npm: replace hardcoded .tgz extension check with immutable_patterns match
- pypi: collapse pypi + pypi-files into a single remote (base_url points
  to files.pythonhosted.org); simple/ requests are transparently fetched
  from pypi.org with no extra config required
- pypi: remove pypi_files_url/pypi_files_remote from pypi and pypi-gitea
- pypi: rewrite check now uses immutable_patterns (consistent with npm)
- Update README for both pypi and npm sections
- Update tests and fixtures to reflect single-remote pypi config
2026-04-27 22:42:23 +10:00
unkinben 8adcbac405 Merge pull request 'feat: add helm chart repository caching proxy' (#17) from benvin/helm-remote into master
ci/woodpecker/tag/docker Pipeline was successful
Reviewed-on: #17
2026-04-27 22:22:36 +10:00
unkinben 4ca89b9159 feat: add helm chart repository caching proxy
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
- Add helm package type with index.yaml as mutable (TTL-based) and
  .tgz chart tarballs as immutable
- Rewrite chart URLs in index.yaml to serve tarballs via proxy cache
- Add text/yaml content-type detection for .yaml/.yml files
- Add hashicorp-helm example remote in remotes.yaml
- Update README with Helm chart repository proxy section
- Add tests for helm mutable patterns and route behaviour
2026-04-27 22:17:31 +10:00
unkinben 25b85ddc92 Merge pull request 'feat: add npm registry caching proxy' (#16) from benvin/npm-remote into master
ci/woodpecker/tag/docker Pipeline was successful
Reviewed-on: #16
2026-04-27 20:30:18 +10:00
unkinben d585ab425c feat: add npm remote type with metadata URL rewriting and caching
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
- Add `npm` package type to config with no built-in mutable defaults;
  users set explicit mutable_patterns (e.g. ^(?!.*\.tgz$).*) and
  immutable_patterns (e.g. \.tgz$) in remotes.yaml
- Rewrite dist.tarball URLs in metadata JSON on the fly so tarball
  downloads pass through the same proxy remote instead of hitting
  npmjs.org directly
- Single-remote design: npm_files_remote points back to itself since
  both metadata and tarballs are served from registry.npmjs.org
- Add .tgz to _get_content_type (application/gzip)
- Add example npm remote to remotes.yaml
- Add npm proxy section to README covering remotes.yaml config,
  client setup (npm/yarn/pnpm), rewriting behaviour, and
  mutable vs immutable path table
- Add tests for mutable pattern matching, URL rewriting, content-type,
  scoped packages, cache miss, and tarball immutability
2026-04-27 20:28:31 +10:00
unkinben 6b1a6c9eb4 Merge pull request 'feat: add PyPI remote type with URL rewriting and basic auth' (#15) from benvin/pypi-remote into master
Reviewed-on: #15
2026-04-27 14:46:27 +10:00
unkinben 5de912db75 docs: describe PyPI remote usage with uv system/user uv.toml
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
2026-04-27 14:37:41 +10:00
unkinben 8e9d313892 feat: add pypi remote type with URL rewriting and basic auth
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
- Add 'pypi' package type to config.py; simple/ paths are mutable by default
- Refactor content-type detection into _get_content_type() helper; add .whl
- Add _resolve_content() which rewrites files host URLs in simple index HTML
  to go through the proxy (pypi_files_url / pypi_files_remote config keys),
  and returns text/html content-type for simple index responses
- Add basic auth support for non-Docker remotes (username + password/token
  in remote config); thread auth through _upstream_reachable and
  check_upstream_changed so mutable TTL checks also authenticate
- Add 'pypi' remote (pypi.org simple index) and 'pypi-files' remote
  (files.pythonhosted.org) to remotes.yaml; add 'pypi-gitea' example for
  Gitea package registries where index and files share the same base URL
- Add unit tests: simple index URL rewriting, HTML content-type, .whl/.tar.gz
  content-types, mutable index detection, and immutable pattern enforcement
2026-04-27 14:31:33 +10:00
unkinben 70cd439961 Merge pull request 'feat: immutable/mutable caching patterns with conditional revalidation and stale fallback' (#14) from benvin/immutable-mutable-patterns into master
ci/woodpecker/tag/docker Pipeline was successful
Reviewed-on: #14
2026-04-27 11:44:49 +10:00
unkinben fe837dabf7 feat: keep stale mutables when upstream is unreachable; update README
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
When a mutable file's TTL expires and the upstream backend cannot be
contacted (network error or timeout), the cached copy is kept and its
TTL refreshed instead of being evicted. This keeps RPM repodata, Alpine
indexes, branch archives, and other mutable data available during
upstream outages.

Adds UpstreamUnreachable exception and _upstream_reachable() helper.
check_upstream_changed() now raises UpstreamUnreachable on network
errors (was silently returning True). handle_expired_mutable() catches
the exception on the check_mutable_updates path and calls
_upstream_reachable() on the plain-expiry path.

README updated to current immutable/mutable terminology and documents
all new caching features.
2026-04-27 11:38:50 +10:00
unkinben 78296dae8f refactor: extract handle_expired_mutable helper; add redownload success test
Deduplicates the expired-mutable TTL/redownload branching logic that
was copied verbatim between get_artifact and docker_v2_proxy. Adds the
missing happy-path test for a changed mutable file that is successfully
re-fetched from upstream.
2026-04-27 11:13:15 +10:00
unkinben 8fe4bac2b9 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 11:00:09 +10:00
unkinben 8bc9285117 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 10:58:59 +10:00
unkinben ce01a94141 feat: rename include/index patterns to immutable/mutable with per-remote TTL
Replace the include_patterns/index_patterns split with a clearer
immutable_patterns/mutable_patterns model:

- immutable_patterns: artifacts cached indefinitely (no TTL)
- mutable_patterns: artifacts that expire and are re-fetched after
  cache.mutable_ttl seconds (replaces cache.index_ttl)

_PACKAGE_INDEX_PATTERNS renamed to _PACKAGE_MUTABLE_PATTERNS; all
built-in package-type index patterns (APKINDEX, repomd, manifests, etc.)
default to the remote's mutable_ttl (default 1 hour).

cache.file_ttl renamed to cache.immutable_ttl for consistency.
Adds github-archive remote to remotes.yaml as a worked example showing
tag archives as immutable and branch archives as mutable (1-day TTL).

docker-compose.yml: fix VERSION=dev → 2.2.2.dev0 (valid PEP 440),
add :z SELinux label to volume mounts.
2026-04-27 00:40:13 +10:00
unkinben 4619ae18d8 Merge pull request 'chore: remove build from tag' (#13) from benvin/docker-compose-build into master
Reviewed-on: #13
2026-04-25 22:29:48 +10:00
unkinben ac51d3a51d chore: remove build from tag
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
- stop building the image on tag events
2026-04-25 22:27:59 +10:00
12 changed files with 1437 additions and 1092 deletions
-1
View File
@@ -35,7 +35,6 @@ env/
# Environment variables # Environment variables
.env .env
remotes.yaml
# Logs # Logs
*.log *.log
-1
View File
@@ -74,4 +74,3 @@ major:
_tag: _tag:
git push origin $(TAG) git push origin $(TAG)
docker-compose build --no-cache --build-arg VERSION=$(TAG:v%=%)
+236 -847
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -6,12 +6,12 @@ services:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
args: args:
- VERSION=dev - VERSION=2.2.2.dev0
ports: ports:
- "8000:8000" - "8000:8000"
volumes: volumes:
- ./remotes.yaml:/app/remotes.yaml:ro - ./remotes.yaml:/app/remotes.yaml:ro,z
- ./ca-bundle.pem:/app/ca-bundle.pem:ro - ./ca-bundle.pem:/app/ca-bundle.pem:ro,z
environment: environment:
- CONFIG_PATH=/app/remotes.yaml - CONFIG_PATH=/app/remotes.yaml
- DBHOST=postgres - DBHOST=postgres
+265
View File
@@ -0,0 +1,265 @@
# Example remotes configuration — copy and adapt for your environment.
#
# immutable_patterns: artifacts cached forever (e.g. release binaries, versioned tags).
# mutable_patterns: artifacts that expire after cache.mutable_ttl seconds and are
# re-fetched from upstream on next request (e.g. index files,
# branch archives). Defaults to the package-type built-ins when
# not set (APKINDEX, repomd.xml, Docker manifests, etc.).
# cache:
# immutable_ttl: TTL for immutable files (0 = forever, rarely needed to change).
# mutable_ttl: TTL in seconds for mutable files. Omit to use the default (3600).
#
# WARNING: this file may contain credentials — do not commit real values.
#
# Global configuration
#s3:
# endpoint: "localhost:9000"
# access_key: "minioadmin"
# secret_key: "minioadmin"
# bucket: "artifacts"
# secure: false
#
#redis:
# url: "redis://localhost:6379/0"
#
#database:
# url: "postgresql://artifacts:artifacts123@localhost:5432/artifacts"
#
remotes:
github:
base_url: "https://github.com"
type: "remote"
package: "generic"
description: "GitHub releases and files"
immutable_patterns:
- "gruntwork-io/terragrunt/.*terragrunt_linux_amd64.*"
- "lxc/incus/.*\\.tar\\.gz$"
- "prometheus/node_exporter/.*/node_exporter-.*\\.linux-amd64\\.tar\\.gz$"
- "VictoriaMetrics/VictoriaMetrics/.*/vmutils-linux-amd64-.*\\.tar\\.gz$"
- "VictoriaMetrics/VictoriaMetrics/.*/victoria-metrics-linux-amd64-.*-cluster\\.tar\\.gz$"
- "VictoriaMetrics/VictoriaMetrics/.*/victoria-logs-linux-amd64-.*\\.tar\\.gz$"
- "VictoriaMetrics/VictoriaMetrics/.*/vlutils-linux-amd64-.*\\.tar\\.gz$"
- "prometheus-community/bind_exporter/.*/bind_exporter-.*\\.linux-amd64\\.tar\\.gz$"
- "prometheus-community/pgbouncer_exporter/.*/pgbouncer_exporter-.*\\.linux-amd64\\.tar\\.gz$"
- "prometheus-community/postgres_exporter/.*/postgres_exporter-.*\\.linux-amd64\\.tar\\.gz$"
- "onedr0p/exportarr/.*/exportarr_.*_linux_amd64\\.tar\\.gz$"
- "tynany/frr_exporter/.*/frr_exporter-.*\\.linux-amd64\\.tar\\.gz$"
- "camptocamp/prometheus-puppetdb-exporter/.*/prometheus-puppetdb-exporter-.*\\.linux-amd64\\.tar\\.gz$"
- "grafana/jsonnet-language-server/.*/jsonnet-language-server_.*_linux_amd64$"
- "helmfile/helmfile/.*/helmfile_.*_linux_amd64\\.tar\\.gz$"
- "helmfile/vals/.*/vals_.*_linux_amd64\\.tar\\.gz$"
- "openbao/openbao-plugins/.*/openbao-plugin-secrets-consul_linux_amd64_.*\\.tar\\.gz$"
- "openbao/openbao-plugins/.*/openbao-plugin-secrets-nomad_linux_amd64_.*\\.tar\\.gz$"
- "apple/foundationdb/.*/libfdb_c\\.x86_64\\.so$"
- "stalwartlabs/stalwart/.*/stalwart-cli-x86_64-unknown-linux-gnu\\.tar\\.gz$"
- "stalwartlabs/stalwart/.*/stalwart-foundationdb-x86_64-unknown-linux-gnu\\.tar\\.gz$"
- "stalwartlabs/stalwart/.*/stalwart-x86_64-unknown-linux-gnu\\.tar\\.gz$"
cache:
immutable_ttl: 0 # Files cached indefinitely
mutable_ttl: 0
github-archive:
base_url: "https://github.com"
type: "remote"
package: "generic"
description: "GitHub repository archive tarballs"
immutable_patterns:
# Tag archives are immutable — a tag never changes
- ".*/archive/refs/tags/.*\\.tar\\.gz$"
mutable_patterns:
# Branch archives can change on every push
- ".*/archive/refs/heads/main\\.tar\\.gz$"
- ".*/archive/refs/heads/master\\.tar\\.gz$"
# Before re-downloading an expired branch archive, check whether it has
# actually changed (304 Not Modified → just refresh the TTL, no transfer).
# Only applies to user-defined mutable_patterns, not package-type defaults.
check_mutable_updates: true
cache:
immutable_ttl: 0 # Tag archives cached indefinitely
mutable_ttl: 86400 # Branch archives refreshed after 1 day
gitea-dl:
base_url: "https://dl.gitea.com"
type: "remote"
package: "generic"
description: "Gitea download site"
immutable_patterns:
- "act_runner/.*/act_runner-.*-linux-amd64$"
cache:
immutable_ttl: 0 # Files cached indefinitely
mutable_ttl: 0
hashicorp-releases:
base_url: "https://releases.hashicorp.com"
type: "remote"
package: "generic"
description: "HashiCorp product releases"
immutable_patterns:
- "terraform/.*terraform_.*_linux_amd64\\.zip$"
- "terraform/.*terraform_.*_windows_amd64\\.zip$"
- "terraform/.*terraform_.*_darwin_amd64\\.zip$"
- "vault/.*vault_.*_linux_amd64\\.zip$"
- "vault/.*vault_.*_windows_amd64\\.zip$"
- "vault/.*vault_.*_darwin_amd64\\.zip$"
- "consul-cni/.*/consul-cni_.*_linux_amd64\\.zip$"
- "consul/.*/consul_.*_linux_amd64\\.zip$"
- "nomad-autoscaler/.*/nomad-autoscaler_.*_linux_amd64\\.zip$"
- "nomad/.*/nomad_.*_linux_amd64\\.zip$"
- "packer/.*/packer_.*_linux_amd64\\.zip$"
cache:
immutable_ttl: 0 # Files cached indefinitely
mutable_ttl: 0
alpine:
base_url: "https://dl-cdn.alpinelinux.org"
type: "remote"
package: "alpine"
description: "Alpine Linux APK package repository"
immutable_patterns:
- ".*/x86_64/.*\\.apk$"
# check_mutable_updates not set: APKINDEX.tar.gz is a package-type default
# and is always re-fetched on expiry — conditional checks are skipped for
# built-in mutable patterns regardless of this flag.
cache:
immutable_ttl: 0 # Files cached indefinitely
mutable_ttl: 7200 # Index files (APKINDEX.tar.gz) cached for 2 hours
almalinux:
base_url: "https://gsl-syd.mm.fcix.net/almalinux"
type: "remote"
package: "rpm"
description: "AlmaLinux RPM package repository"
immutable_patterns:
- ".*/x86_64/.*\\.rpm$"
- ".*/noarch/.*\\.rpm$"
- ".*/repodata/.*$"
- ".*\\.rpm$" # Allow all RPM files
# repomd.xml / repodata are package-type defaults — always re-fetched on
# expiry. check_mutable_updates would only apply to any custom
# mutable_patterns added here.
cache:
immutable_ttl: 0 # Files cached indefinitely
mutable_ttl: 7200 # Metadata files cached for 2 hours
epel:
base_url: "http://mirror.aarnet.edu.au/pub/epel"
type: "remote"
package: "rpm"
description: "EPEL (Extra Packages for Enterprise Linux)"
immutable_patterns:
- "8/Everything/x86_64/.*\\.rpm$"
- "9/Everything/x86_64/.*\\.rpm$"
- "10/Everything/x86_64/.*\\.rpm$"
- ".*/noarch/.*\\.rpm$"
- ".*/repodata/.*$"
cache:
immutable_ttl: 0 # Files cached indefinitely
mutable_ttl: 7200 # Metadata files cached for 2 hours
fedora:
base_url: "https://gsl-syd.mm.fcix.net/fedora/linux"
type: "remote"
package: "rpm"
description: "Fedora Linux RPM package repository"
immutable_patterns:
- "releases/.*/Everything/x86_64/.*\\.rpm$"
- "updates/.*/Everything/x86_64/.*\\.rpm$"
- "development/.*/Everything/x86_64/.*\\.rpm$"
- ".*/noarch/.*\\.rpm$"
- "updates/.*/Everything/x86_64/repodata/.*$"
cache:
immutable_ttl: 0 # Files cached indefinitely
mutable_ttl: 300 # Metadata files cached for 5 minutes
ghcr:
base_url: "https://ghcr.io"
type: "remote"
package: "docker"
description: "GitHub Container Registry"
# username: "your-github-username"
# password: "your-github-pat" # needs read:packages scope
# Docker manifest/tag-list patterns are package-type defaults — always
# re-fetched on expiry. check_mutable_updates only applies to any custom
# mutable_patterns you add (e.g. a metadata endpoint).
cache:
immutable_ttl: 0
mutable_ttl: 300
dockerhub:
base_url: "https://registry-1.docker.io"
type: "remote"
package: "docker"
description: "Docker Hub registry"
cache:
immutable_ttl: 0
mutable_ttl: 300
pypi:
base_url: "https://files.pythonhosted.org"
type: "remote"
package: "pypi"
description: "Python Package Index — simple index and package files via a single remote"
# simple/ requests are transparently fetched from pypi.org; package files come from
# files.pythonhosted.org (base_url). URLs in the simple index are rewritten to this remote.
check_mutable_updates: true
immutable_patterns:
- "packages/.*\\.whl$"
- "packages/.*\\.whl\\.metadata$"
- "packages/.*\\.tar\\.gz$"
- "packages/.*\\.zip$"
- "packages/.*\\.egg$"
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 — simple index and files at the same host"
# username: "your-gitea-username"
# password: "your-personal-access-token" # needs package:read scope
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
npm:
base_url: "https://registry.npmjs.org"
type: "remote"
package: "npm"
description: "npm registry — package metadata with tarball URL rewriting"
check_mutable_updates: true
immutable_patterns:
- \.tgz$
mutable_patterns:
- ^(?!.*\.tgz$).*
cache:
immutable_ttl: 0
mutable_ttl: 600 # Package metadata refreshed after 10 minutes
hashicorp-helm:
base_url: "https://helm.releases.hashicorp.com"
type: "remote"
package: "helm"
description: "HashiCorp Helm chart repository (Vault, Consul, Nomad, etc.)"
check_mutable_updates: true
immutable_patterns:
- "\\.tgz$"
cache:
immutable_ttl: 0 # Chart tarballs are versioned — cache forever
mutable_ttl: 3600 # index.yaml refreshed after 1 hour
local-generic:
type: "local"
package: "generic"
description: "Local generic file repository"
cache:
immutable_ttl: 0 # Files cached indefinitely
mutable_ttl: 0
+44 -9
View File
@@ -19,18 +19,20 @@ class RedisCache:
self.client = None self.client = None
self.available = False self.available = False
def is_index_file(self, file_path: str, patterns: list[str] | None = None) -> bool: def is_mutable_file(self, file_path: str, patterns: list[str] | None = None) -> bool:
"""Return True if file_path matches any of the index patterns.""" """Return True if file_path matches any of the mutable patterns."""
if patterns is None: if patterns is None:
patterns = [] patterns = []
return any(re.search(p, file_path) for p in patterns) return any(re.search(p, file_path) for p in patterns)
def get_index_cache_key(self, remote_name: str, path: str) -> str: def get_index_cache_key(self, remote_name: str, path: str) -> str:
"""Generate cache key for index files"""
return f"index:{remote_name}:{hashlib.sha256(path.encode()).hexdigest()[:16]}" return f"index:{remote_name}:{hashlib.sha256(path.encode()).hexdigest()[:16]}"
def is_index_valid(self, remote_name: str, path: str, ttl_override: int = None) -> bool: def get_mutable_meta_key(self, remote_name: str, path: str) -> str:
"""Check if index file is still valid (not expired)""" return f"mutable:meta:{remote_name}:{hashlib.sha256(path.encode()).hexdigest()[:16]}"
def is_index_valid(self, remote_name: str, path: str) -> bool:
"""Check if mutable file is still within its TTL window."""
if not self.available: if not self.available:
return False return False
@@ -41,7 +43,7 @@ class RedisCache:
return False return False
def mark_index_cached(self, remote_name: str, path: str, ttl: int = 300) -> None: def mark_index_cached(self, remote_name: str, path: str, ttl: int = 300) -> None:
"""Mark index file as cached with TTL""" """Set or refresh the TTL key for a mutable file."""
if not self.available: if not self.available:
return return
@@ -51,13 +53,45 @@ class RedisCache:
except Exception: except Exception:
pass pass
def store_mutable_meta(self, remote_name: str, path: str, etag: str | None, last_modified: str | None) -> None:
"""Persist ETag and Last-Modified for future conditional requests."""
if not self.available:
return
data = {}
if etag:
data["etag"] = etag
if last_modified:
data["last_modified"] = last_modified
if not data:
return
try:
self.client.hset(self.get_mutable_meta_key(remote_name, path), mapping=data)
except Exception:
pass
def get_mutable_meta(self, remote_name: str, path: str) -> dict:
"""Return stored ETag/Last-Modified for a mutable file, or {}."""
if not self.available:
return {}
try:
return self.client.hgetall(self.get_mutable_meta_key(remote_name, path)) or {}
except Exception:
return {}
def delete_mutable_meta(self, remote_name: str, path: str) -> None:
if not self.available:
return
try:
self.client.delete(self.get_mutable_meta_key(remote_name, path))
except Exception:
pass
def cleanup_expired_index(self, storage, remote_name: str, path: str) -> None: def cleanup_expired_index(self, storage, remote_name: str, path: str) -> None:
"""Remove expired index from S3 storage""" """Remove an expired mutable file from S3 and clear its Redis meta."""
if not self.available: if not self.available:
return return
try: try:
# Construct the URL the same way as in the main flow
import os import os
from .config import ConfigManager from .config import ConfigManager
@@ -69,9 +103,10 @@ class RedisCache:
if remote_config: if remote_config:
base_url = remote_config.get("base_url") base_url = remote_config.get("base_url")
if base_url: if base_url:
# Use hierarchical path-based key (same as cache_single_artifact)
s3_key = storage.get_object_key(remote_name, path) s3_key = storage.get_object_key(remote_name, path)
if storage.exists(s3_key): if storage.exists(s3_key):
storage.client.delete_object(Bucket=storage.bucket, Key=s3_key) storage.client.delete_object(Bucket=storage.bucket, Key=s3_key)
except Exception: except Exception:
pass pass
self.delete_mutable_meta(remote_name, path)
+24 -15
View File
@@ -3,7 +3,7 @@ import os
import yaml import yaml
_PACKAGE_INDEX_PATTERNS: dict[str, list[str]] = { _PACKAGE_MUTABLE_PATTERNS: dict[str, list[str]] = {
"alpine": [ "alpine": [
r"APKINDEX\.tar\.gz$", r"APKINDEX\.tar\.gz$",
], ],
@@ -18,6 +18,13 @@ _PACKAGE_INDEX_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": [],
"helm": [
r"index\.yaml$",
],
"generic": [], "generic": [],
} }
@@ -55,26 +62,21 @@ class ConfigManager:
self._check_reload() self._check_reload()
return self.config.get("remotes", {}).get(remote_name) return self.config.get("remotes", {}).get(remote_name)
def get_repository_patterns(self, remote_name: str, repo_path: str) -> list: def get_immutable_patterns(self, remote_name: str, repo_path: str = "") -> list[str]:
remote_config = self.get_remote_config(remote_name) remote_config = self.get_remote_config(remote_name)
if not remote_config: if not remote_config:
return [] return []
repositories = remote_config.get("repositories", {}) repositories = remote_config.get("repositories", {})
# Handle both dict (GitHub style) and list (Alpine style) repositories
if isinstance(repositories, dict): if isinstance(repositories, dict):
repo_config = repositories.get(repo_path) repo_config = repositories.get(repo_path)
if repo_config: if repo_config:
patterns = repo_config.get("include_patterns", []) patterns = repo_config.get("immutable_patterns", [])
else: else:
patterns = remote_config.get("include_patterns", []) patterns = remote_config.get("immutable_patterns", [])
elif isinstance(repositories, list):
# For Alpine, repositories is just a list of allowed repo names
# Pattern matching is handled by the main include_patterns
patterns = remote_config.get("include_patterns", [])
else: else:
patterns = remote_config.get("include_patterns", []) patterns = remote_config.get("immutable_patterns", [])
return patterns return patterns
@@ -129,18 +131,25 @@ class ConfigManager:
db_url = f"postgresql://{db_user}:{db_pass}@{db_host}:{db_port}/{db_name}" db_url = f"postgresql://{db_user}:{db_pass}@{db_host}:{db_port}/{db_name}"
return {"url": db_url} return {"url": db_url}
def get_index_patterns(self, remote_name: str) -> list[str]: def get_user_mutable_patterns(self, remote_name: str) -> list[str]:
"""Return index-file patterns for a remote. """Return only user-configured mutable_patterns, excluding package-type defaults."""
remote_config = self.get_remote_config(remote_name)
if not remote_config:
return []
return remote_config.get("mutable_patterns", [])
def get_mutable_patterns(self, remote_name: str) -> list[str]:
"""Return mutable-file patterns for a remote (TTL is configured per-remote in cache.index_ttl).
Merges the package-level defaults with any extra patterns listed under Merges the package-level defaults with any extra patterns listed under
``index_patterns`` in the remote's config. ``mutable_patterns`` in the remote's config.
""" """
remote_config = self.get_remote_config(remote_name) remote_config = self.get_remote_config(remote_name)
if not remote_config: if not remote_config:
return [] return []
package = remote_config.get("package", "generic") package = remote_config.get("package", "generic")
defaults = _PACKAGE_INDEX_PATTERNS.get(package, []) defaults = _PACKAGE_MUTABLE_PATTERNS.get(package, [])
extra = remote_config.get("index_patterns", []) extra = remote_config.get("mutable_patterns", [])
return defaults + [p for p in extra if p not in defaults] return defaults + [p for p in extra if p not in defaults]
def get_cache_config(self, remote_name: str) -> dict: def get_cache_config(self, remote_name: str) -> dict:
+184 -71
View File
@@ -1,3 +1,4 @@
import base64
import hashlib import hashlib
import json import json
import logging import logging
@@ -32,6 +33,10 @@ 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__)
@@ -87,8 +92,10 @@ def flush_cache(
if cache_type in ["all", "index"]: if cache_type in ["all", "index"]:
if remote: if remote:
patterns.append(f"index:{remote}:*") patterns.append(f"index:{remote}:*")
patterns.append(f"mutable:meta:{remote}:*")
else: else:
patterns.append("index:*") patterns.append("index:*")
patterns.append("mutable:meta:*")
if cache_type in ["all", "metrics"]: if cache_type in ["all", "metrics"]:
if remote: if remote:
@@ -153,23 +160,24 @@ async def construct_remote_url(remote_name: str, path: str) -> str:
if not base_url: if not base_url:
raise HTTPException(status_code=500, detail=f"No base_url configured for remote '{remote_name}'") raise HTTPException(status_code=500, detail=f"No base_url configured for remote '{remote_name}'")
# Handle Docker registry URLs
if remote_config.get("package") == "docker": if remote_config.get("package") == "docker":
# Convert Docker paths to v2 API format
# e.g., library/nginx/manifests/latest -> v2/library/nginx/manifests/latest
return f"{base_url}/v2/{path}" return f"{base_url}/v2/{path}"
# PyPI splits index and files across two hosts; redirect simple/ requests to pypi.org
if remote_config.get("package") == "pypi" and base_url.rstrip("/") == "https://files.pythonhosted.org" and "simple/" in path:
return f"https://pypi.org/{path}"
return f"{base_url}/{path}" return f"{base_url}/{path}"
async def check_artifact_patterns(remote_name: str, repo_path: str, file_path: str, full_path: str) -> bool: async def check_artifact_patterns(remote_name: str, repo_path: str, file_path: str, full_path: str) -> bool:
# First check if this is an index file - always allow index files # Mutable files (index files) are always allowed through
index_patterns = config.get_index_patterns(remote_name) mutable_patterns = config.get_mutable_patterns(remote_name)
if cache.is_index_file(file_path, index_patterns) or cache.is_index_file(full_path, index_patterns): if cache.is_mutable_file(file_path, mutable_patterns) or cache.is_mutable_file(full_path, mutable_patterns):
return True return True
# Then check basic include patterns # Check immutable include patterns
patterns = config.get_repository_patterns(remote_name, repo_path) patterns = config.get_immutable_patterns(remote_name, repo_path)
if not patterns: if not patterns:
return True # Allow all if no patterns configured return True # Allow all if no patterns configured
@@ -183,7 +191,6 @@ async def check_artifact_patterns(remote_name: str, repo_path: str, file_path: s
if not pattern_matched: if not pattern_matched:
return False return False
# All remotes now use pattern-based filtering only - no additional checks needed
return True return True
@@ -203,8 +210,11 @@ 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 for Docker registry requests # Prepare headers
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"] = (
@@ -215,6 +225,8 @@ 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)
@@ -241,14 +253,150 @@ async def cache_single_artifact(url: str, remote_name: str, path: str) -> dict:
"storage_path": storage_path, "storage_path": storage_path,
"size": len(response.content), "size": len(response.content),
"status": "cached", "status": "cached",
"etag": response.headers.get("ETag"),
"last_modified": response.headers.get("Last-Modified"),
} }
except Exception as e: except Exception as e:
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]:
username = remote_cfg.get("username")
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)
if not meta:
return True
headers = dict(auth_headers or {})
if meta.get("etag"):
headers["If-None-Match"] = meta["etag"]
if meta.get("last_modified"):
headers["If-Modified-Since"] = meta["last_modified"]
if not (meta.get("etag") or meta.get("last_modified")):
return True
try:
async with httpx.AsyncClient(follow_redirects=True) as client:
response = await client.head(remote_url, headers=headers)
return response.status_code != 304
except (httpx.NetworkError, httpx.TimeoutException) as exc:
raise UpstreamUnreachable(str(exc)) from exc
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"
if filename.endswith((".yaml", ".yml")):
return "text/yaml"
return "application/octet-stream"
def _resolve_content(
data: bytes,
path: str,
filename: str,
remote_config: dict,
request: Request,
remote_name: str = "",
) -> tuple[bytes, str]:
"""Return (possibly-rewritten data, content_type) for a cached artifact."""
if remote_config.get("package") == "pypi":
immutable = remote_config.get("immutable_patterns", [])
if not any(re.search(p, path) for p in immutable):
proxy_base = str(request.base_url).rstrip("/")
base_url = remote_config.get("base_url", "").rstrip("/")
data = data.replace(
base_url.encode(),
f"{proxy_base}/api/v1/remote/{remote_name}".encode(),
)
return data, "text/html; charset=utf-8"
if remote_config.get("package") == "npm":
immutable = remote_config.get("immutable_patterns", [])
if not any(re.search(p, path) for p in immutable):
proxy_base = str(request.base_url).rstrip("/")
base_url = remote_config.get("base_url", "").rstrip("/")
data = data.replace(
base_url.encode(),
f"{proxy_base}/api/v1/remote/{remote_name}".encode(),
)
return data, "application/json"
if remote_config.get("package") == "helm" and filename == "index.yaml":
proxy_base = str(request.base_url).rstrip("/")
base_url = remote_config.get("base_url", "").rstrip("/")
data = data.replace(
base_url.encode(),
f"{proxy_base}/api/v1/remote/{remote_name}".encode(),
)
return data, "text/yaml"
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(remote_name: str, path: str): async def get_artifact(request: Request, 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:
@@ -297,46 +445,25 @@ async def get_artifact(remote_name: str, path: str):
if not storage.exists(cached_key): if not storage.exists(cached_key):
cached_key = None cached_key = None
# For index files, check Redis TTL validity # For mutable files, check Redis TTL validity
filename = os.path.basename(path) filename = os.path.basename(path)
is_index = cache.is_index_file(path, config.get_index_patterns(remote_name)) is_mutable = cache.is_mutable_file(path, config.get_mutable_patterns(remote_name))
if cached_key and is_index: if cached_key and is_mutable:
# Index file exists, but check if it's still valid
if not cache.is_index_valid(remote_name, path): if not cache.is_index_valid(remote_name, path):
# Index has expired, remove it from S3 if not await handle_expired_mutable(remote_name, path, remote_url):
logger.info(f"Index EXPIRED: {remote_name}/{path} - removing from cache") cached_key = None
cache.cleanup_expired_index(storage, remote_name, path)
cached_key = None # Force re-download
if cached_key: if cached_key:
# Return cached artifact # Return cached artifact
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, remote_name)
# 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(
@@ -359,38 +486,23 @@ async def get_artifact(remote_name: str, path: str):
logger.error(f"Cache ADD FAILED: {remote_name}/{path} - {result['error']}") logger.error(f"Cache ADD FAILED: {remote_name}/{path} - {result['error']}")
raise HTTPException(status_code=502, detail=f"Failed to fetch artifact: {result['error']}") raise HTTPException(status_code=502, detail=f"Failed to fetch artifact: {result['error']}")
# Mark index files as cached in Redis if this was a new download # Mark mutable files as cached in Redis with TTL
if result["status"] == "cached" and is_index: if result["status"] == "cached" and is_mutable:
# Get TTL from remote config
cache_config = config.get_cache_config(remote_name) cache_config = config.get_cache_config(remote_name)
index_ttl = cache_config.get("index_ttl", 300) # Default 5 minutes mutable_ttl = cache_config.get("mutable_ttl", 3600)
cache.mark_index_cached(remote_name, path, index_ttl) cache.mark_index_cached(remote_name, path, mutable_ttl)
logger.info(f"Index file cached with TTL: {remote_name}/{path} (ttl: {index_ttl}s)") logger.info(f"Mutable file cached with TTL: {remote_name}/{path} (ttl: {mutable_ttl}s)")
if result.get("etag") or result.get("last_modified"):
cache.store_mutable_meta(remote_name, path, result.get("etag"), result.get("last_modified"))
# Now return the cached artifact # Now return the cached artifact
try: try:
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, remote_name)
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))
@@ -424,8 +536,8 @@ async def docker_v2_proxy(request: Request, remote_name: str, path: str):
if remote_config.get("package") != "docker": if remote_config.get("package") != "docker":
raise HTTPException(status_code=400, detail=f"Remote '{remote_name}' is not a docker remote") raise HTTPException(status_code=400, detail=f"Remote '{remote_name}' is not a docker remote")
# Check include_patterns against the image name (e.g. "library/nginx") # Check immutable_patterns against the image name (e.g. "library/nginx")
patterns = config.get_repository_patterns(remote_name, "") patterns = config.get_immutable_patterns(remote_name, "")
if patterns: if patterns:
path_parts = path.split("/") path_parts = path.split("/")
image_name = "/".join(path_parts[:2]) if len(path_parts) >= 2 else path image_name = "/".join(path_parts[:2]) if len(path_parts) >= 2 else path
@@ -439,12 +551,11 @@ async def docker_v2_proxy(request: Request, remote_name: str, path: str):
if not storage.exists(cached_key): if not storage.exists(cached_key):
cached_key = None cached_key = None
is_index = cache.is_index_file(path, config.get_index_patterns(remote_name)) is_mutable = cache.is_mutable_file(path, config.get_mutable_patterns(remote_name))
if cached_key and is_index: if cached_key and is_mutable:
if not cache.is_index_valid(remote_name, path): if not cache.is_index_valid(remote_name, path):
logger.info(f"Index EXPIRED: {remote_name}/{path} - removing from cache") if not await handle_expired_mutable(remote_name, path, remote_url):
cache.cleanup_expired_index(storage, remote_name, path)
cached_key = None cached_key = None
if not cached_key: if not cached_key:
@@ -452,11 +563,13 @@ async def docker_v2_proxy(request: Request, remote_name: str, path: str):
result = await cache_single_artifact(remote_url, remote_name, path) result = await cache_single_artifact(remote_url, remote_name, path)
if result["status"] == "error": if result["status"] == "error":
raise HTTPException(status_code=502, detail=f"Failed to fetch: {result['error']}") raise HTTPException(status_code=502, detail=f"Failed to fetch: {result['error']}")
if result["status"] == "cached" and is_index: if result["status"] == "cached" and is_mutable:
cache_config = config.get_cache_config(remote_name) cache_config = config.get_cache_config(remote_name)
index_ttl = cache_config.get("index_ttl", 300) mutable_ttl = cache_config.get("mutable_ttl", 3600)
cache.mark_index_cached(remote_name, path, index_ttl) cache.mark_index_cached(remote_name, path, mutable_ttl)
logger.info(f"Index file cached with TTL: {remote_name}/{path} (ttl: {index_ttl}s)") logger.info(f"Mutable file cached with TTL: {remote_name}/{path} (ttl: {mutable_ttl}s)")
if result.get("etag") or result.get("last_modified"):
cache.store_mutable_meta(remote_name, path, result.get("etag"), result.get("last_modified"))
artifact_data = storage.download_object(storage.get_object_key(remote_name, path)) artifact_data = storage.download_object(storage.get_object_key(remote_name, path))
+46 -12
View File
@@ -22,47 +22,81 @@ TEST_REMOTES = {
"base_url": "https://dl-cdn.alpinelinux.org", "base_url": "https://dl-cdn.alpinelinux.org",
"type": "remote", "type": "remote",
"package": "alpine", "package": "alpine",
"include_patterns": [".*/x86_64/.*\\.apk$"], "immutable_patterns": [".*/x86_64/.*\\.apk$"],
"cache": {"file_ttl": 0, "index_ttl": 3600}, "cache": {"immutable_ttl": 0, "mutable_ttl": 3600},
}, },
"rpm-test": { "rpm-test": {
"base_url": "https://example.com/rpm", "base_url": "https://example.com/rpm",
"type": "remote", "type": "remote",
"package": "rpm", "package": "rpm",
"include_patterns": [".*/x86_64/.*\\.rpm$", ".*/repodata/.*$"], "immutable_patterns": [".*/x86_64/.*\\.rpm$", ".*/repodata/.*$"],
"cache": {"file_ttl": 0, "index_ttl": 3600}, "cache": {"immutable_ttl": 0, "mutable_ttl": 3600},
}, },
"docker-test": { "docker-test": {
"base_url": "https://registry.example.com", "base_url": "https://registry.example.com",
"type": "remote", "type": "remote",
"package": "docker", "package": "docker",
"cache": {"file_ttl": 0, "index_ttl": 300}, "cache": {"immutable_ttl": 0, "mutable_ttl": 300},
}, },
"docker-restricted": { "docker-restricted": {
"base_url": "https://registry.example.com", "base_url": "https://registry.example.com",
"type": "remote", "type": "remote",
"package": "docker", "package": "docker",
"include_patterns": ["^library/nginx"], "immutable_patterns": ["^library/nginx"],
"cache": {"file_ttl": 0, "index_ttl": 300}, "cache": {"immutable_ttl": 0, "mutable_ttl": 300},
}, },
"generic-test": { "generic-test": {
"base_url": "https://releases.example.com", "base_url": "https://releases.example.com",
"type": "remote", "type": "remote",
"package": "generic", "package": "generic",
"include_patterns": [".*\\.tar\\.gz$"], "immutable_patterns": [".*\\.tar\\.gz$"],
"cache": {"file_ttl": 0, "index_ttl": 0}, "cache": {"immutable_ttl": 0, "mutable_ttl": 0},
}, },
"custom-index-test": { "custom-index-test": {
"base_url": "https://example.com", "base_url": "https://example.com",
"type": "remote", "type": "remote",
"package": "generic", "package": "generic",
"index_patterns": ["metadata\\.json$"], "mutable_patterns": ["metadata\\.json$"],
"cache": {"file_ttl": 0, "index_ttl": 600}, "cache": {"immutable_ttl": 0, "mutable_ttl": 600},
},
"check-mutable-test": {
"base_url": "https://example.com",
"type": "remote",
"package": "generic",
"mutable_patterns": ["metadata\\.json$"],
"check_mutable_updates": True,
"cache": {"immutable_ttl": 0, "mutable_ttl": 600},
}, },
"local-test": { "local-test": {
"type": "local", "type": "local",
"package": "generic", "package": "generic",
"cache": {"file_ttl": 0, "index_ttl": 0}, "cache": {"immutable_ttl": 0, "mutable_ttl": 0},
},
"pypi-test": {
"base_url": "https://files.pythonhosted.org",
"type": "remote",
"package": "pypi",
"immutable_patterns": [
r"packages/.*\.whl$",
r"packages/.*\.whl\.metadata$",
r"packages/.*\.tar\.gz$",
],
"cache": {"immutable_ttl": 0, "mutable_ttl": 600},
},
"npm-test": {
"base_url": "https://registry.npmjs.org",
"type": "remote",
"package": "npm",
"immutable_patterns": [r"\.tgz$"],
"mutable_patterns": [r"^(?!.*\.tgz$).*"],
"cache": {"immutable_ttl": 0, "mutable_ttl": 600},
},
"helm-test": {
"base_url": "https://helm.releases.hashicorp.com",
"type": "remote",
"package": "helm",
"immutable_patterns": [r"\.tgz$"],
"cache": {"immutable_ttl": 0, "mutable_ttl": 3600},
}, },
} }
} }
+109 -61
View File
@@ -1,4 +1,4 @@
"""Tests for RedisCache, focusing on is_index_file with configurable patterns.""" """Tests for RedisCache, focusing on is_mutable_file with configurable patterns."""
import hashlib import hashlib
from unittest.mock import ANY, MagicMock, patch from unittest.mock import ANY, MagicMock, patch
@@ -6,7 +6,7 @@ from unittest.mock import ANY, MagicMock, patch
import pytest import pytest
from artifactapi.cache import RedisCache from artifactapi.cache import RedisCache
from artifactapi.config import _PACKAGE_INDEX_PATTERNS from artifactapi.config import _PACKAGE_MUTABLE_PATTERNS
@pytest.fixture @pytest.fixture
@@ -38,139 +38,139 @@ def cache_with_redis(mock_redis_client):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# is_index_file — alpine patterns # is_mutable_file — alpine patterns
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestIsIndexFileAlpine: class TestIsMutableFileAlpine:
def test_apkindex_tarball_is_index(self, bare_cache): def test_apkindex_tarball_is_index(self, bare_cache):
patterns = _PACKAGE_INDEX_PATTERNS["alpine"] patterns = _PACKAGE_MUTABLE_PATTERNS["alpine"]
assert bare_cache.is_index_file("alpine/v3.18/x86_64/APKINDEX.tar.gz", patterns) assert bare_cache.is_mutable_file("alpine/v3.18/x86_64/APKINDEX.tar.gz", patterns)
def test_nested_apkindex_is_index(self, bare_cache): def test_nested_apkindex_is_index(self, bare_cache):
patterns = _PACKAGE_INDEX_PATTERNS["alpine"] patterns = _PACKAGE_MUTABLE_PATTERNS["alpine"]
assert bare_cache.is_index_file("mirrors/dl-cdn/alpine/v3.19/community/x86_64/APKINDEX.tar.gz", patterns) assert bare_cache.is_mutable_file("mirrors/dl-cdn/alpine/v3.19/community/x86_64/APKINDEX.tar.gz", patterns)
def test_apk_package_is_not_index(self, bare_cache): def test_apk_package_is_not_index(self, bare_cache):
patterns = _PACKAGE_INDEX_PATTERNS["alpine"] patterns = _PACKAGE_MUTABLE_PATTERNS["alpine"]
assert not bare_cache.is_index_file("alpine/v3.18/x86_64/musl-1.2.4-r2.apk", patterns) assert not bare_cache.is_mutable_file("alpine/v3.18/x86_64/musl-1.2.4-r2.apk", patterns)
def test_random_tarball_is_not_index(self, bare_cache): def test_random_tarball_is_not_index(self, bare_cache):
patterns = _PACKAGE_INDEX_PATTERNS["alpine"] patterns = _PACKAGE_MUTABLE_PATTERNS["alpine"]
assert not bare_cache.is_index_file("some/path/archive.tar.gz", patterns) assert not bare_cache.is_mutable_file("some/path/archive.tar.gz", patterns)
def test_apkindex_signature_file_is_not_index(self, bare_cache): def test_apkindex_signature_file_is_not_index(self, bare_cache):
# Signature file adjacent to the index should not be treated as an index # Signature file adjacent to the index should not be treated as an index
patterns = _PACKAGE_INDEX_PATTERNS["alpine"] patterns = _PACKAGE_MUTABLE_PATTERNS["alpine"]
assert not bare_cache.is_index_file("alpine/v3.18/x86_64/APKINDEX.tar.gz.sig", patterns) assert not bare_cache.is_mutable_file("alpine/v3.18/x86_64/APKINDEX.tar.gz.sig", patterns)
def test_apkindex_tmp_file_is_not_index(self, bare_cache): def test_apkindex_tmp_file_is_not_index(self, bare_cache):
patterns = _PACKAGE_INDEX_PATTERNS["alpine"] patterns = _PACKAGE_MUTABLE_PATTERNS["alpine"]
assert not bare_cache.is_index_file("alpine/v3.18/x86_64/APKINDEX.tar.gz.tmp", patterns) assert not bare_cache.is_mutable_file("alpine/v3.18/x86_64/APKINDEX.tar.gz.tmp", patterns)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# is_index_file — rpm patterns # is_mutable_file — rpm patterns
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestIsIndexFileRpm: class TestIsMutableFileRpm:
def test_repomd_xml_is_index(self, bare_cache): def test_repomd_xml_is_index(self, bare_cache):
patterns = _PACKAGE_INDEX_PATTERNS["rpm"] patterns = _PACKAGE_MUTABLE_PATTERNS["rpm"]
assert bare_cache.is_index_file("almalinux/9/x86_64/repomd.xml", patterns) assert bare_cache.is_mutable_file("almalinux/9/x86_64/repomd.xml", patterns)
def test_repodata_primary_xml_gz_is_index(self, bare_cache): def test_repodata_primary_xml_gz_is_index(self, bare_cache):
patterns = _PACKAGE_INDEX_PATTERNS["rpm"] patterns = _PACKAGE_MUTABLE_PATTERNS["rpm"]
assert bare_cache.is_index_file("repo/repodata/primary.xml.gz", patterns) assert bare_cache.is_mutable_file("repo/repodata/primary.xml.gz", patterns)
def test_repodata_sqlite_is_index(self, bare_cache): def test_repodata_sqlite_is_index(self, bare_cache):
patterns = _PACKAGE_INDEX_PATTERNS["rpm"] patterns = _PACKAGE_MUTABLE_PATTERNS["rpm"]
assert bare_cache.is_index_file("repo/repodata/primary.sqlite", patterns) assert bare_cache.is_mutable_file("repo/repodata/primary.sqlite", patterns)
def test_repodata_sqlite_bz2_is_index(self, bare_cache): def test_repodata_sqlite_bz2_is_index(self, bare_cache):
patterns = _PACKAGE_INDEX_PATTERNS["rpm"] patterns = _PACKAGE_MUTABLE_PATTERNS["rpm"]
assert bare_cache.is_index_file("repo/repodata/other.sqlite.bz2", patterns) assert bare_cache.is_mutable_file("repo/repodata/other.sqlite.bz2", patterns)
def test_repodata_yaml_xz_is_index(self, bare_cache): def test_repodata_yaml_xz_is_index(self, bare_cache):
patterns = _PACKAGE_INDEX_PATTERNS["rpm"] patterns = _PACKAGE_MUTABLE_PATTERNS["rpm"]
assert bare_cache.is_index_file("repo/repodata/comps.yaml.xz", patterns) assert bare_cache.is_mutable_file("repo/repodata/comps.yaml.xz", patterns)
def test_packages_gz_pattern_matches_any_path(self, bare_cache): def test_packages_gz_pattern_matches_any_path(self, bare_cache):
# The Packages.gz$ regex is a carryover from the original hardcoded logic and # The Packages.gz$ regex is a carryover from the original hardcoded logic and
# deliberately matches any path ending in Packages.gz — including Debian-style paths. # deliberately matches any path ending in Packages.gz — including Debian-style paths.
# This test documents that intentional behaviour. # This test documents that intentional behaviour.
patterns = _PACKAGE_INDEX_PATTERNS["rpm"] patterns = _PACKAGE_MUTABLE_PATTERNS["rpm"]
assert bare_cache.is_index_file("debian/dists/stable/main/binary-amd64/Packages.gz", patterns) assert bare_cache.is_mutable_file("debian/dists/stable/main/binary-amd64/Packages.gz", patterns)
def test_rpm_package_is_not_index(self, bare_cache): def test_rpm_package_is_not_index(self, bare_cache):
patterns = _PACKAGE_INDEX_PATTERNS["rpm"] patterns = _PACKAGE_MUTABLE_PATTERNS["rpm"]
assert not bare_cache.is_index_file("almalinux/9/x86_64/Packages/bash-5.1.8.x86_64.rpm", patterns) assert not bare_cache.is_mutable_file("almalinux/9/x86_64/Packages/bash-5.1.8.x86_64.rpm", patterns)
def test_arbitrary_xml_outside_repodata_is_not_index(self, bare_cache): def test_arbitrary_xml_outside_repodata_is_not_index(self, bare_cache):
patterns = _PACKAGE_INDEX_PATTERNS["rpm"] patterns = _PACKAGE_MUTABLE_PATTERNS["rpm"]
assert not bare_cache.is_index_file("some/path/config.xml", patterns) assert not bare_cache.is_mutable_file("some/path/config.xml", patterns)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# is_index_file — docker patterns # is_mutable_file — docker patterns
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestIsIndexFileDocker: class TestIsMutableFileDocker:
def test_tag_manifest_is_index(self, bare_cache): def test_tag_manifest_is_index(self, bare_cache):
patterns = _PACKAGE_INDEX_PATTERNS["docker"] patterns = _PACKAGE_MUTABLE_PATTERNS["docker"]
assert bare_cache.is_index_file("library/nginx/manifests/latest", patterns) assert bare_cache.is_mutable_file("library/nginx/manifests/latest", patterns)
def test_version_tag_manifest_is_index(self, bare_cache): def test_version_tag_manifest_is_index(self, bare_cache):
patterns = _PACKAGE_INDEX_PATTERNS["docker"] patterns = _PACKAGE_MUTABLE_PATTERNS["docker"]
assert bare_cache.is_index_file("library/nginx/manifests/1.25.3", patterns) assert bare_cache.is_mutable_file("library/nginx/manifests/1.25.3", patterns)
def test_hyphenated_tag_manifest_is_index(self, bare_cache): def test_hyphenated_tag_manifest_is_index(self, bare_cache):
patterns = _PACKAGE_INDEX_PATTERNS["docker"] patterns = _PACKAGE_MUTABLE_PATTERNS["docker"]
assert bare_cache.is_index_file("library/nginx/manifests/latest-rc", patterns) assert bare_cache.is_mutable_file("library/nginx/manifests/latest-rc", patterns)
def test_numeric_date_tag_manifest_is_index(self, bare_cache): def test_numeric_date_tag_manifest_is_index(self, bare_cache):
patterns = _PACKAGE_INDEX_PATTERNS["docker"] patterns = _PACKAGE_MUTABLE_PATTERNS["docker"]
assert bare_cache.is_index_file("library/nginx/manifests/20240101", patterns) assert bare_cache.is_mutable_file("library/nginx/manifests/20240101", patterns)
def test_digest_manifest_is_not_index(self, bare_cache): def test_digest_manifest_is_not_index(self, bare_cache):
patterns = _PACKAGE_INDEX_PATTERNS["docker"] patterns = _PACKAGE_MUTABLE_PATTERNS["docker"]
digest = "sha256:" + "a" * 64 digest = "sha256:" + "a" * 64
assert not bare_cache.is_index_file(f"library/nginx/manifests/{digest}", patterns) assert not bare_cache.is_mutable_file(f"library/nginx/manifests/{digest}", patterns)
def test_tags_list_is_index(self, bare_cache): def test_tags_list_is_index(self, bare_cache):
patterns = _PACKAGE_INDEX_PATTERNS["docker"] patterns = _PACKAGE_MUTABLE_PATTERNS["docker"]
assert bare_cache.is_index_file("library/nginx/tags/list", patterns) assert bare_cache.is_mutable_file("library/nginx/tags/list", patterns)
def test_blob_is_not_index(self, bare_cache): def test_blob_is_not_index(self, bare_cache):
patterns = _PACKAGE_INDEX_PATTERNS["docker"] patterns = _PACKAGE_MUTABLE_PATTERNS["docker"]
assert not bare_cache.is_index_file("library/nginx/blobs/sha256:abc123", patterns) assert not bare_cache.is_mutable_file("library/nginx/blobs/sha256:abc123", patterns)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# is_index_file — edge cases # is_mutable_file — edge cases
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestIsIndexFileEdgeCases: class TestIsMutableFileEdgeCases:
def test_empty_patterns_nothing_is_index(self, bare_cache): def test_empty_patterns_nothing_is_index(self, bare_cache):
assert not bare_cache.is_index_file("APKINDEX.tar.gz", []) assert not bare_cache.is_mutable_file("APKINDEX.tar.gz", [])
assert not bare_cache.is_index_file("repomd.xml", []) assert not bare_cache.is_mutable_file("repomd.xml", [])
assert not bare_cache.is_index_file("library/nginx/manifests/latest", []) assert not bare_cache.is_mutable_file("library/nginx/manifests/latest", [])
def test_none_patterns_nothing_is_index(self, bare_cache): def test_none_patterns_nothing_is_index(self, bare_cache):
assert not bare_cache.is_index_file("APKINDEX.tar.gz", None) assert not bare_cache.is_mutable_file("APKINDEX.tar.gz", None)
assert not bare_cache.is_index_file("repomd.xml", None) assert not bare_cache.is_mutable_file("repomd.xml", None)
def test_custom_patterns_match(self, bare_cache): def test_custom_patterns_match(self, bare_cache):
patterns = [r"metadata\.json$", r"index\.yaml$"] patterns = [r"metadata\.json$", r"index\.yaml$"]
assert bare_cache.is_index_file("repo/metadata.json", patterns) assert bare_cache.is_mutable_file("repo/metadata.json", patterns)
assert bare_cache.is_index_file("repo/subdir/index.yaml", patterns) assert bare_cache.is_mutable_file("repo/subdir/index.yaml", patterns)
assert not bare_cache.is_index_file("repo/data.tar.gz", patterns) assert not bare_cache.is_mutable_file("repo/data.tar.gz", patterns)
def test_custom_pattern_does_not_match_standard_index(self, bare_cache): def test_custom_pattern_does_not_match_standard_index(self, bare_cache):
patterns = [r"metadata\.json$"] patterns = [r"metadata\.json$"]
assert not bare_cache.is_index_file("APKINDEX.tar.gz", patterns) assert not bare_cache.is_mutable_file("APKINDEX.tar.gz", patterns)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -235,3 +235,51 @@ class TestIndexValidity:
# client is None when Redis is unavailable — setex cannot be called # client is None when Redis is unavailable — setex cannot be called
assert unavailable_cache.client is None assert unavailable_cache.client is None
unavailable_cache.mark_index_cached("remote", "some/path", 300) # must not raise unavailable_cache.mark_index_cached("remote", "some/path", 300) # must not raise
# ---------------------------------------------------------------------------
# mutable meta (ETag / Last-Modified storage)
# ---------------------------------------------------------------------------
class TestMutableMeta:
def test_meta_key_format(self, bare_cache):
path = "repo/metadata.json"
expected_hash = hashlib.sha256(path.encode()).hexdigest()[:16]
assert bare_cache.get_mutable_meta_key("myremote", path) == f"mutable:meta:myremote:{expected_hash}"
def test_meta_key_hash_is_16_chars(self, bare_cache):
key = bare_cache.get_mutable_meta_key("remote", "some/path/file.json")
assert len(key.split(":")[-1]) == 16
def test_store_and_retrieve_etag(self, cache_with_redis, mock_redis_client):
mock_redis_client.hgetall.return_value = {"etag": '"abc123"'}
cache_with_redis.store_mutable_meta("remote", "path/meta.json", '"abc123"', None)
mock_redis_client.hset.assert_called_once()
meta = cache_with_redis.get_mutable_meta("remote", "path/meta.json")
assert meta["etag"] == '"abc123"'
def test_store_and_retrieve_last_modified(self, cache_with_redis, mock_redis_client):
lm = "Mon, 01 Jan 2024 00:00:00 GMT"
mock_redis_client.hgetall.return_value = {"last_modified": lm}
cache_with_redis.store_mutable_meta("remote", "path/meta.json", None, lm)
meta = cache_with_redis.get_mutable_meta("remote", "path/meta.json")
assert meta["last_modified"] == lm
def test_store_no_op_when_both_none(self, cache_with_redis, mock_redis_client):
cache_with_redis.store_mutable_meta("remote", "path/meta.json", None, None)
mock_redis_client.hset.assert_not_called()
def test_store_no_op_when_unavailable(self, unavailable_cache):
unavailable_cache.store_mutable_meta("remote", "path", "etag", None) # must not raise
def test_get_returns_empty_when_unavailable(self, unavailable_cache):
assert unavailable_cache.get_mutable_meta("remote", "path") == {}
def test_delete_removes_meta_key(self, cache_with_redis, mock_redis_client):
expected_key = cache_with_redis.get_mutable_meta_key("remote", "path/meta.json")
cache_with_redis.delete_mutable_meta("remote", "path/meta.json")
mock_redis_client.delete.assert_called_once_with(expected_key)
def test_delete_no_op_when_unavailable(self, unavailable_cache):
unavailable_cache.delete_mutable_meta("remote", "path") # must not raise
+122 -38
View File
@@ -1,4 +1,4 @@
"""Tests for ConfigManager, focusing on get_index_patterns (new logic).""" """Tests for ConfigManager, focusing on get_mutable_patterns and get_immutable_patterns."""
import os import os
@@ -21,45 +21,44 @@ def make_config(tmp_path):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# get_index_patterns # get_mutable_patterns
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestGetIndexPatterns: class TestGetMutablePatterns:
def test_alpine_returns_package_defaults(self, make_config): def test_alpine_returns_package_defaults(self, make_config):
cfg = make_config({"r": {"type": "remote", "package": "alpine", "base_url": "https://x.com"}}) cfg = make_config({"r": {"type": "remote", "package": "alpine", "base_url": "https://x.com"}})
patterns = cfg.get_index_patterns("r") patterns = cfg.get_mutable_patterns("r")
# Assert against literal strings, not the live constant, so a rename doesn't mask a regression
assert r"APKINDEX\.tar\.gz$" in patterns assert r"APKINDEX\.tar\.gz$" in patterns
def test_rpm_returns_package_defaults(self, make_config): def test_rpm_returns_package_defaults(self, make_config):
cfg = make_config({"r": {"type": "remote", "package": "rpm", "base_url": "https://x.com"}}) cfg = make_config({"r": {"type": "remote", "package": "rpm", "base_url": "https://x.com"}})
patterns = cfg.get_index_patterns("r") patterns = cfg.get_mutable_patterns("r")
assert r"repomd\.xml$" in patterns assert r"repomd\.xml$" in patterns
assert any("repodata" in p for p in patterns) assert any("repodata" in p for p in patterns)
def test_docker_returns_package_defaults(self, make_config): def test_docker_returns_package_defaults(self, make_config):
cfg = make_config({"r": {"type": "remote", "package": "docker", "base_url": "https://x.com"}}) cfg = make_config({"r": {"type": "remote", "package": "docker", "base_url": "https://x.com"}})
patterns = cfg.get_index_patterns("r") patterns = cfg.get_mutable_patterns("r")
assert any("manifests" in p for p in patterns) assert any("manifests" in p for p in patterns)
assert any("tags/list" in p for p in patterns) assert any("tags/list" in p for p in patterns)
def test_generic_returns_empty_list(self, make_config): def test_generic_returns_empty_list(self, make_config):
cfg = make_config({"r": {"type": "remote", "package": "generic", "base_url": "https://x.com"}}) cfg = make_config({"r": {"type": "remote", "package": "generic", "base_url": "https://x.com"}})
assert cfg.get_index_patterns("r") == [] assert cfg.get_mutable_patterns("r") == []
def test_unknown_remote_returns_empty_list(self, make_config): def test_unknown_remote_returns_empty_list(self, make_config):
cfg = make_config({}) cfg = make_config({})
assert cfg.get_index_patterns("nonexistent") == [] assert cfg.get_mutable_patterns("nonexistent") == []
def test_missing_package_field_defaults_to_generic(self, make_config): def test_missing_package_field_defaults_to_generic(self, make_config):
cfg = make_config({"r": {"type": "remote", "base_url": "https://x.com"}}) cfg = make_config({"r": {"type": "remote", "base_url": "https://x.com"}})
assert cfg.get_index_patterns("r") == [] assert cfg.get_mutable_patterns("r") == []
def test_unknown_package_type_returns_empty_list(self, make_config): def test_unknown_package_type_returns_empty_list(self, make_config):
# A mis-spelled package type silently returns [] — this is a known footgun # A mis-spelled package type silently returns [] — this is a known footgun
cfg = make_config({"r": {"type": "remote", "package": "deb", "base_url": "https://x.com"}}) cfg = make_config({"r": {"type": "remote", "package": "deb", "base_url": "https://x.com"}})
assert cfg.get_index_patterns("r") == [] assert cfg.get_mutable_patterns("r") == []
def test_extra_patterns_appended_after_defaults(self, make_config): def test_extra_patterns_appended_after_defaults(self, make_config):
cfg = make_config( cfg = make_config(
@@ -68,11 +67,11 @@ class TestGetIndexPatterns:
"type": "remote", "type": "remote",
"package": "alpine", "package": "alpine",
"base_url": "https://x.com", "base_url": "https://x.com",
"index_patterns": [r"custom\.json$"], "mutable_patterns": [r"custom\.json$"],
} }
} }
) )
patterns = cfg.get_index_patterns("r") patterns = cfg.get_mutable_patterns("r")
assert r"APKINDEX\.tar\.gz$" in patterns assert r"APKINDEX\.tar\.gz$" in patterns
assert r"custom\.json$" in patterns assert r"custom\.json$" in patterns
# Defaults come first # Defaults come first
@@ -85,11 +84,11 @@ class TestGetIndexPatterns:
"type": "remote", "type": "remote",
"package": "alpine", "package": "alpine",
"base_url": "https://x.com", "base_url": "https://x.com",
"index_patterns": [], "mutable_patterns": [],
} }
} }
) )
assert r"APKINDEX\.tar\.gz$" in cfg.get_index_patterns("r") assert r"APKINDEX\.tar\.gz$" in cfg.get_mutable_patterns("r")
def test_duplicate_extra_pattern_not_added_twice(self, make_config): def test_duplicate_extra_pattern_not_added_twice(self, make_config):
existing = r"APKINDEX\.tar\.gz$" existing = r"APKINDEX\.tar\.gz$"
@@ -99,11 +98,11 @@ class TestGetIndexPatterns:
"type": "remote", "type": "remote",
"package": "alpine", "package": "alpine",
"base_url": "https://x.com", "base_url": "https://x.com",
"index_patterns": [existing], "mutable_patterns": [existing],
} }
} }
) )
patterns = cfg.get_index_patterns("r") patterns = cfg.get_mutable_patterns("r")
assert patterns.count(existing) == 1 assert patterns.count(existing) == 1
def test_generic_with_only_extra_patterns(self, make_config): def test_generic_with_only_extra_patterns(self, make_config):
@@ -113,11 +112,11 @@ class TestGetIndexPatterns:
"type": "remote", "type": "remote",
"package": "generic", "package": "generic",
"base_url": "https://x.com", "base_url": "https://x.com",
"index_patterns": [r"meta\.json$", r"index\.yaml$"], "mutable_patterns": [r"meta\.json$", r"index\.yaml$"],
} }
} }
) )
assert cfg.get_index_patterns("r") == [r"meta\.json$", r"index\.yaml$"] assert cfg.get_mutable_patterns("r") == [r"meta\.json$", r"index\.yaml$"]
def test_rpm_extra_patterns_merged(self, make_config): def test_rpm_extra_patterns_merged(self, make_config):
cfg = make_config( cfg = make_config(
@@ -126,41 +125,93 @@ class TestGetIndexPatterns:
"type": "remote", "type": "remote",
"package": "rpm", "package": "rpm",
"base_url": "https://x.com", "base_url": "https://x.com",
"index_patterns": [r"custom-meta\.xml$"], "mutable_patterns": [r"custom-meta\.xml$"],
} }
} }
) )
patterns = cfg.get_index_patterns("r") patterns = cfg.get_mutable_patterns("r")
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_helm_returns_index_yaml_as_mutable(self, make_config):
cfg = make_config({"r": {"type": "remote", "package": "helm", "base_url": "https://helm.example.com"}})
patterns = cfg.get_mutable_patterns("r")
assert r"index\.yaml$" in patterns
def test_helm_chart_tarballs_not_mutable_by_default(self, make_config):
import re
cfg = make_config({"r": {"type": "remote", "package": "helm", "base_url": "https://helm.example.com"}})
patterns = cfg.get_mutable_patterns("r")
# Only index.yaml is mutable; .tgz chart tarballs are not
assert not any(re.search(p, "vault-0.29.1.tgz") for p in patterns)
assert not any(re.search(p, "consul-1.5.0.tgz") 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_repository_patterns # get_immutable_patterns
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestGetRepositoryPatterns: class TestGetImmutablePatterns:
def test_returns_include_patterns(self, make_config): def test_returns_immutable_patterns(self, make_config):
cfg = make_config( cfg = make_config(
{ {
"r": { "r": {
"type": "remote", "type": "remote",
"package": "generic", "package": "generic",
"base_url": "https://x.com", "base_url": "https://x.com",
"include_patterns": [r".*\.tar\.gz$"], "immutable_patterns": [r".*\.tar\.gz$"],
} }
} }
) )
assert cfg.get_repository_patterns("r", "") == [r".*\.tar\.gz$"] assert cfg.get_immutable_patterns("r") == [r".*\.tar\.gz$"]
def test_returns_empty_for_missing_remote(self, make_config): def test_returns_empty_for_missing_remote(self, make_config):
cfg = make_config({}) cfg = make_config({})
assert cfg.get_repository_patterns("nonexistent", "") == [] assert cfg.get_immutable_patterns("nonexistent") == []
def test_returns_empty_when_no_patterns_configured(self, make_config): def test_returns_empty_when_no_patterns_configured(self, make_config):
cfg = make_config({"r": {"type": "remote", "package": "generic", "base_url": "https://x.com"}}) cfg = make_config({"r": {"type": "remote", "package": "generic", "base_url": "https://x.com"}})
assert cfg.get_repository_patterns("r", "") == [] assert cfg.get_immutable_patterns("r") == []
def test_multiple_patterns_returned(self, make_config): def test_multiple_patterns_returned(self, make_config):
patterns = [r".*\.rpm$", r".*/repodata/.*$"] patterns = [r".*\.rpm$", r".*/repodata/.*$"]
@@ -170,11 +221,11 @@ class TestGetRepositoryPatterns:
"type": "remote", "type": "remote",
"package": "rpm", "package": "rpm",
"base_url": "https://x.com", "base_url": "https://x.com",
"include_patterns": patterns, "immutable_patterns": patterns,
} }
} }
) )
assert cfg.get_repository_patterns("r", "") == patterns assert cfg.get_immutable_patterns("r") == patterns
def test_dict_keyed_repositories_returns_per_repo_patterns(self, make_config): def test_dict_keyed_repositories_returns_per_repo_patterns(self, make_config):
cfg = make_config( cfg = make_config(
@@ -183,14 +234,14 @@ class TestGetRepositoryPatterns:
"type": "remote", "type": "remote",
"package": "generic", "package": "generic",
"base_url": "https://x.com", "base_url": "https://x.com",
"include_patterns": [r".*\.tar\.gz$"], "immutable_patterns": [r".*\.tar\.gz$"],
"repositories": { "repositories": {
"/path/to/repo": {"include_patterns": [r".*\.rpm$"]}, "/path/to/repo": {"immutable_patterns": [r".*\.rpm$"]},
}, },
} }
} }
) )
assert cfg.get_repository_patterns("r", "/path/to/repo") == [r".*\.rpm$"] assert cfg.get_immutable_patterns("r", "/path/to/repo") == [r".*\.rpm$"]
def test_dict_keyed_repositories_falls_back_to_remote_patterns(self, make_config): def test_dict_keyed_repositories_falls_back_to_remote_patterns(self, make_config):
cfg = make_config( cfg = make_config(
@@ -199,14 +250,47 @@ class TestGetRepositoryPatterns:
"type": "remote", "type": "remote",
"package": "generic", "package": "generic",
"base_url": "https://x.com", "base_url": "https://x.com",
"include_patterns": [r".*\.tar\.gz$"], "immutable_patterns": [r".*\.tar\.gz$"],
"repositories": { "repositories": {
"/path/to/repo": {"include_patterns": [r".*\.rpm$"]}, "/path/to/repo": {"immutable_patterns": [r".*\.rpm$"]},
}, },
} }
} }
) )
assert cfg.get_repository_patterns("r", "/unknown/path") == [r".*\.tar\.gz$"] assert cfg.get_immutable_patterns("r", "/unknown/path") == [r".*\.tar\.gz$"]
# ---------------------------------------------------------------------------
# get_user_mutable_patterns
# ---------------------------------------------------------------------------
class TestGetUserMutablePatterns:
def test_returns_only_user_patterns(self, make_config):
cfg = make_config(
{
"r": {
"type": "remote",
"package": "alpine",
"base_url": "https://x.com",
"mutable_patterns": [r"custom\.json$"],
}
}
)
assert cfg.get_user_mutable_patterns("r") == [r"custom\.json$"]
def test_excludes_package_defaults(self, make_config):
# Package defaults (APKINDEX etc.) must NOT appear here
cfg = make_config({"r": {"type": "remote", "package": "alpine", "base_url": "https://x.com"}})
assert cfg.get_user_mutable_patterns("r") == []
def test_returns_empty_for_missing_remote(self, make_config):
cfg = make_config({})
assert cfg.get_user_mutable_patterns("nonexistent") == []
def test_returns_empty_when_key_absent(self, make_config):
cfg = make_config({"r": {"type": "remote", "package": "generic", "base_url": "https://x.com"}})
assert cfg.get_user_mutable_patterns("r") == []
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -222,11 +306,11 @@ class TestGetCacheConfig:
"type": "remote", "type": "remote",
"package": "generic", "package": "generic",
"base_url": "https://x.com", "base_url": "https://x.com",
"cache": {"file_ttl": 0, "index_ttl": 7200}, "cache": {"immutable_ttl": 0, "mutable_ttl": 7200},
} }
} }
) )
assert cfg.get_cache_config("r") == {"file_ttl": 0, "index_ttl": 7200} assert cfg.get_cache_config("r") == {"immutable_ttl": 0, "mutable_ttl": 7200}
def test_returns_empty_dict_for_missing_remote(self, make_config): def test_returns_empty_dict_for_missing_remote(self, make_config):
cfg = make_config({}) cfg = make_config({})
+397 -27
View File
@@ -25,7 +25,7 @@ def mock_storage():
@pytest.fixture @pytest.fixture
def mock_cache(): def mock_cache():
m = MagicMock() m = MagicMock()
m.is_index_file.return_value = False m.is_mutable_file.return_value = False
m.is_index_valid.return_value = True m.is_index_valid.return_value = True
m.available = False m.available = False
m.client = None m.client = None
@@ -123,7 +123,7 @@ class TestDockerProxy:
).encode() ).encode()
deps["storage"].exists.return_value = True deps["storage"].exists.return_value = True
deps["storage"].download_object.return_value = manifest deps["storage"].download_object.return_value = manifest
deps["cache"].is_index_file.return_value = True deps["cache"].is_mutable_file.return_value = True
deps["cache"].is_index_valid.return_value = True deps["cache"].is_index_valid.return_value = True
response = client.get("/v2/docker-restricted/library/nginx/manifests/latest") response = client.get("/v2/docker-restricted/library/nginx/manifests/latest")
@@ -140,7 +140,7 @@ class TestDockerProxy:
).encode() ).encode()
deps["storage"].exists.return_value = True deps["storage"].exists.return_value = True
deps["storage"].download_object.return_value = manifest deps["storage"].download_object.return_value = manifest
deps["cache"].is_index_file.return_value = True deps["cache"].is_mutable_file.return_value = True
deps["cache"].is_index_valid.return_value = True deps["cache"].is_index_valid.return_value = True
response = client.get("/v2/docker-test/library/nginx/manifests/latest") response = client.get("/v2/docker-test/library/nginx/manifests/latest")
@@ -158,7 +158,7 @@ class TestDockerProxy:
).encode() ).encode()
deps["storage"].exists.return_value = True deps["storage"].exists.return_value = True
deps["storage"].download_object.return_value = manifest deps["storage"].download_object.return_value = manifest
deps["cache"].is_index_file.return_value = True deps["cache"].is_mutable_file.return_value = True
deps["cache"].is_index_valid.return_value = True deps["cache"].is_index_valid.return_value = True
response = client.get("/v2/docker-test/library/nginx/manifests/latest") response = client.get("/v2/docker-test/library/nginx/manifests/latest")
@@ -170,7 +170,7 @@ class TestDockerProxy:
manifest = json.dumps({"mediaType": "application/vnd.oci.image.manifest.v1+json", "layers": []}).encode() manifest = json.dumps({"mediaType": "application/vnd.oci.image.manifest.v1+json", "layers": []}).encode()
deps["storage"].exists.return_value = True deps["storage"].exists.return_value = True
deps["storage"].download_object.return_value = manifest deps["storage"].download_object.return_value = manifest
deps["cache"].is_index_file.return_value = False deps["cache"].is_mutable_file.return_value = False
client.get("/v2/docker-test/library/nginx/manifests/latest") client.get("/v2/docker-test/library/nginx/manifests/latest")
deps["metrics"].record_cache_hit.assert_called_once_with("docker-test", ANY) deps["metrics"].record_cache_hit.assert_called_once_with("docker-test", ANY)
@@ -185,7 +185,7 @@ class TestDockerProxy:
).encode() ).encode()
deps["storage"].exists.return_value = True deps["storage"].exists.return_value = True
deps["storage"].download_object.return_value = manifest deps["storage"].download_object.return_value = manifest
deps["cache"].is_index_file.return_value = False deps["cache"].is_mutable_file.return_value = False
response = client.head("/v2/docker-test/library/nginx/manifests/latest") response = client.head("/v2/docker-test/library/nginx/manifests/latest")
assert response.status_code == 200 assert response.status_code == 200
@@ -201,7 +201,7 @@ class TestDockerProxy:
).encode() ).encode()
deps["storage"].exists.return_value = False deps["storage"].exists.return_value = False
deps["storage"].download_object.return_value = manifest deps["storage"].download_object.return_value = manifest
deps["cache"].is_index_file.return_value = True deps["cache"].is_mutable_file.return_value = True
with patch( with patch(
"artifactapi.main.cache_single_artifact", "artifactapi.main.cache_single_artifact",
@@ -223,7 +223,7 @@ class TestDockerProxy:
).encode() ).encode()
deps["storage"].exists.return_value = False deps["storage"].exists.return_value = False
deps["storage"].download_object.return_value = manifest deps["storage"].download_object.return_value = manifest
deps["cache"].is_index_file.return_value = True deps["cache"].is_mutable_file.return_value = True
with patch( with patch(
"artifactapi.main.cache_single_artifact", "artifactapi.main.cache_single_artifact",
@@ -244,10 +244,11 @@ class TestDockerProxy:
} }
).encode() ).encode()
deps["storage"].exists.return_value = True # cached in S3 deps["storage"].exists.return_value = True # cached in S3
deps["cache"].is_index_file.return_value = True deps["cache"].is_mutable_file.return_value = True
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,
@@ -278,7 +279,7 @@ class TestGenericArtifactRoute:
deps = patched_deps deps = patched_deps
deps["storage"].exists.return_value = True deps["storage"].exists.return_value = True
deps["storage"].download_object.return_value = b"tar content" deps["storage"].download_object.return_value = b"tar content"
deps["cache"].is_index_file.return_value = False deps["cache"].is_mutable_file.return_value = False
response = client.get("/api/v1/remote/generic-test/some/path/archive.tar.gz") response = client.get("/api/v1/remote/generic-test/some/path/archive.tar.gz")
assert response.status_code == 200 assert response.status_code == 200
@@ -289,7 +290,7 @@ class TestGenericArtifactRoute:
deps = patched_deps deps = patched_deps
deps["storage"].exists.return_value = True deps["storage"].exists.return_value = True
deps["storage"].download_object.return_value = b"content" deps["storage"].download_object.return_value = b"content"
deps["cache"].is_index_file.return_value = False deps["cache"].is_mutable_file.return_value = False
response = client.get("/api/v1/remote/generic-test/some/path/archive.tar.gz") response = client.get("/api/v1/remote/generic-test/some/path/archive.tar.gz")
disposition = response.headers["content-disposition"] disposition = response.headers["content-disposition"]
@@ -301,7 +302,7 @@ class TestGenericArtifactRoute:
content = b"some artifact content bytes" content = b"some artifact content bytes"
deps["storage"].exists.return_value = True deps["storage"].exists.return_value = True
deps["storage"].download_object.return_value = content deps["storage"].download_object.return_value = content
deps["cache"].is_index_file.return_value = False deps["cache"].is_mutable_file.return_value = False
response = client.get("/api/v1/remote/generic-test/some/path/archive.tar.gz") response = client.get("/api/v1/remote/generic-test/some/path/archive.tar.gz")
assert response.headers["X-Artifact-Size"] == str(len(content)) assert response.headers["X-Artifact-Size"] == str(len(content))
@@ -310,7 +311,7 @@ class TestGenericArtifactRoute:
deps = patched_deps deps = patched_deps
deps["storage"].exists.return_value = True deps["storage"].exists.return_value = True
deps["storage"].download_object.return_value = b"content" deps["storage"].download_object.return_value = b"content"
deps["cache"].is_index_file.return_value = False deps["cache"].is_mutable_file.return_value = False
client.get("/api/v1/remote/generic-test/some/path/archive.tar.gz") client.get("/api/v1/remote/generic-test/some/path/archive.tar.gz")
deps["metrics"].record_cache_hit.assert_called_once_with("generic-test", ANY) deps["metrics"].record_cache_hit.assert_called_once_with("generic-test", ANY)
@@ -319,7 +320,7 @@ class TestGenericArtifactRoute:
deps = patched_deps deps = patched_deps
deps["storage"].exists.return_value = True deps["storage"].exists.return_value = True
deps["storage"].download_object.return_value = b"content" deps["storage"].download_object.return_value = b"content"
deps["cache"].is_index_file.return_value = False deps["cache"].is_mutable_file.return_value = False
client.get("/api/v1/remote/generic-test/some/path/archive.tar.gz") client.get("/api/v1/remote/generic-test/some/path/archive.tar.gz")
deps["database"].record_artifact_mapping.assert_called_once() deps["database"].record_artifact_mapping.assert_called_once()
@@ -328,7 +329,7 @@ class TestGenericArtifactRoute:
deps = patched_deps deps = patched_deps
deps["storage"].exists.return_value = True deps["storage"].exists.return_value = True
deps["storage"].download_object.return_value = b"rpm bytes" deps["storage"].download_object.return_value = b"rpm bytes"
deps["cache"].is_index_file.return_value = False deps["cache"].is_mutable_file.return_value = False
response = client.get("/api/v1/remote/rpm-test/almalinux/9/x86_64/bash-5.1.8.x86_64.rpm") response = client.get("/api/v1/remote/rpm-test/almalinux/9/x86_64/bash-5.1.8.x86_64.rpm")
assert response.status_code == 200 assert response.status_code == 200
@@ -338,7 +339,7 @@ class TestGenericArtifactRoute:
deps = patched_deps deps = patched_deps
deps["storage"].exists.return_value = True deps["storage"].exists.return_value = True
deps["storage"].download_object.return_value = b"<?xml version='1.0'?>" deps["storage"].download_object.return_value = b"<?xml version='1.0'?>"
deps["cache"].is_index_file.return_value = False deps["cache"].is_mutable_file.return_value = False
response = client.get("/api/v1/remote/rpm-test/repo/repodata/primary.xml") response = client.get("/api/v1/remote/rpm-test/repo/repodata/primary.xml")
assert response.status_code == 200 assert response.status_code == 200
@@ -348,7 +349,7 @@ class TestGenericArtifactRoute:
deps = patched_deps deps = patched_deps
deps["storage"].exists.return_value = False deps["storage"].exists.return_value = False
deps["storage"].download_object.return_value = b"fresh content" deps["storage"].download_object.return_value = b"fresh content"
deps["cache"].is_index_file.return_value = False deps["cache"].is_mutable_file.return_value = False
with patch( with patch(
"artifactapi.main.cache_single_artifact", "artifactapi.main.cache_single_artifact",
@@ -365,7 +366,7 @@ class TestGenericArtifactRoute:
deps = patched_deps deps = patched_deps
deps["storage"].exists.return_value = False deps["storage"].exists.return_value = False
deps["storage"].download_object.return_value = b"fresh content" deps["storage"].download_object.return_value = b"fresh content"
deps["cache"].is_index_file.return_value = False deps["cache"].is_mutable_file.return_value = False
with patch( with patch(
"artifactapi.main.cache_single_artifact", "artifactapi.main.cache_single_artifact",
@@ -380,7 +381,7 @@ class TestGenericArtifactRoute:
deps = patched_deps deps = patched_deps
deps["storage"].exists.return_value = False deps["storage"].exists.return_value = False
deps["storage"].download_object.return_value = b"APKINDEX content" deps["storage"].download_object.return_value = b"APKINDEX content"
deps["cache"].is_index_file.return_value = True deps["cache"].is_mutable_file.return_value = True
with patch( with patch(
"artifactapi.main.cache_single_artifact", "artifactapi.main.cache_single_artifact",
@@ -395,7 +396,7 @@ class TestGenericArtifactRoute:
def test_upstream_error_returns_502(self, client, patched_deps): def test_upstream_error_returns_502(self, client, patched_deps):
deps = patched_deps deps = patched_deps
deps["storage"].exists.return_value = False deps["storage"].exists.return_value = False
deps["cache"].is_index_file.return_value = False deps["cache"].is_mutable_file.return_value = False
with patch( with patch(
"artifactapi.main.cache_single_artifact", "artifactapi.main.cache_single_artifact",
@@ -406,19 +407,116 @@ class TestGenericArtifactRoute:
assert response.status_code == 502 assert response.status_code == 502
def test_index_file_bypasses_include_patterns(self, client, patched_deps): def test_mutable_file_bypasses_immutable_patterns(self, client, patched_deps):
"""Index files must be served even when they don't match include_patterns.""" """Mutable files must be served even when they don't match immutable_patterns."""
deps = patched_deps deps = patched_deps
deps["storage"].exists.return_value = True deps["storage"].exists.return_value = True
deps["storage"].download_object.return_value = b"APKINDEX content" deps["storage"].download_object.return_value = b"APKINDEX content"
deps["cache"].is_index_file.return_value = True deps["cache"].is_mutable_file.return_value = True
deps["cache"].is_index_valid.return_value = True deps["cache"].is_index_valid.return_value = True
# APKINDEX.tar.gz does not match alpine-test's include_patterns (.*.apk$), # APKINDEX.tar.gz does not match alpine-test's immutable_patterns (.*.apk$),
# but since is_index_file returns True it must be allowed through. # but since is_mutable_file returns True it must be allowed through.
response = client.get("/api/v1/remote/alpine-test/alpine/v3.18/x86_64/APKINDEX.tar.gz") response = client.get("/api/v1/remote/alpine-test/alpine/v3.18/x86_64/APKINDEX.tar.gz")
assert response.status_code == 200 assert response.status_code == 200
def test_mutable_unchanged_refreshes_ttl_without_redownload(self, client, patched_deps):
"""When check_mutable_updates=True and upstream says 304, TTL is refreshed in place."""
deps = patched_deps
deps["storage"].exists.return_value = True
deps["storage"].download_object.return_value = b"metadata content"
# File is mutable and its TTL has expired
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=False):
response = client.get("/api/v1/remote/check-mutable-test/metadata.json")
assert response.status_code == 200
deps["cache"].mark_index_cached.assert_called()
# S3 object must NOT have been deleted (no re-download)
deps["storage"].client.delete_object.assert_not_called()
def test_mutable_changed_triggers_redownload(self, client, patched_deps):
"""When check_mutable_updates=True and upstream says 200, cache is invalidated."""
deps = patched_deps
deps["storage"].exists.return_value = False
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": "error", "error": "upstream gone"}
response = client.get("/api/v1/remote/check-mutable-test/metadata.json")
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):
"""When check_mutable_updates is not set, expired mutable files are always re-fetched."""
deps = patched_deps
deps["storage"].exists.return_value = False
deps["cache"].is_mutable_file.return_value = True
deps["cache"].is_index_valid.return_value = False
with patch("artifactapi.main.check_upstream_changed", new_callable=AsyncMock) as mock_check:
with patch("artifactapi.main.cache_single_artifact", new_callable=AsyncMock) as mock_cache:
mock_cache.return_value = {"status": "error", "error": "upstream gone"}
client.get("/api/v1/remote/custom-index-test/metadata.json")
mock_check.assert_not_called()
def test_local_repo_file_not_found_returns_404(self, client, patched_deps): def test_local_repo_file_not_found_returns_404(self, client, patched_deps):
deps = patched_deps deps = patched_deps
deps["database"].get_local_file_metadata.return_value = None deps["database"].get_local_file_metadata.return_value = None
@@ -519,8 +617,8 @@ class TestCacheFlushEndpoint:
deps["cache"].available = True deps["cache"].available = True
redis_mock = MagicMock() redis_mock = MagicMock()
deps["cache"].client = redis_mock deps["cache"].client = redis_mock
# First pattern (index:*) returns keys; subsequent pattern returns nothing # index:* returns keys; mutable:meta:* and metrics:* return nothing
redis_mock.keys.side_effect = [["index:test:abc", "index:test:def"], []] redis_mock.keys.side_effect = [["index:test:abc", "index:test:def"], [], []]
deps["storage"].client.list_objects_v2.return_value = {} deps["storage"].client.list_objects_v2.return_value = {}
response = client.put("/cache/flush") response = client.put("/cache/flush")
@@ -554,3 +652,275 @@ 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-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-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-test/packages/requests-2.31.0.tar.gz")
assert response.status_code == 200
assert "application/gzip" in response.headers["content-type"]
def test_unknown_extension_on_pypi_remote_returns_403(self, client, patched_deps):
"""Paths that don't match immutable_patterns and aren't mutable are blocked."""
response = client.get("/api/v1/remote/pypi-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"]
# ---------------------------------------------------------------------------
# Helm remote /api/v1/remote/helm-test/...
# ---------------------------------------------------------------------------
class TestHelmRemote:
def test_index_yaml_is_mutable(self, client, patched_deps):
"""index.yaml is detected as mutable (package-type default)."""
deps = patched_deps
index = b"apiVersion: v1\nentries:\n vault:\n - urls:\n - https://helm.releases.hashicorp.com/vault-0.29.1.tgz\n"
deps["storage"].exists.return_value = True
deps["storage"].download_object.return_value = index
deps["cache"].is_mutable_file.return_value = True
deps["cache"].is_index_valid.return_value = True
response = client.get("/api/v1/remote/helm-test/index.yaml")
assert response.status_code == 200
deps["cache"].mark_index_cached.assert_not_called()
def test_index_yaml_urls_rewritten_to_proxy(self, client, patched_deps):
"""base_url chart URLs in a cached index.yaml are rewritten to our proxy."""
deps = patched_deps
index = b"apiVersion: v1\nentries:\n vault:\n - urls:\n - https://helm.releases.hashicorp.com/vault-0.29.1.tgz\n"
deps["storage"].exists.return_value = True
deps["storage"].download_object.return_value = index
deps["cache"].is_mutable_file.return_value = True
deps["cache"].is_index_valid.return_value = True
response = client.get("/api/v1/remote/helm-test/index.yaml")
assert response.status_code == 200
assert b"helm.releases.hashicorp.com" not in response.content
assert b"/api/v1/remote/helm-test/vault-0.29.1.tgz" in response.content
def test_index_yaml_content_type_is_yaml(self, client, patched_deps):
deps = patched_deps
deps["storage"].exists.return_value = True
deps["storage"].download_object.return_value = b"apiVersion: v1\nentries: {}\n"
deps["cache"].is_mutable_file.return_value = True
deps["cache"].is_index_valid.return_value = True
response = client.get("/api/v1/remote/helm-test/index.yaml")
assert response.status_code == 200
assert "text/yaml" in response.headers["content-type"]
def test_chart_tarball_immutable_returns_gzip_content_type(self, client, patched_deps):
"""Versioned chart tarballs match immutable_patterns and are served as binary."""
deps = patched_deps
deps["storage"].exists.return_value = True
deps["storage"].download_object.return_value = b"\x1f\x8b chart bytes"
deps["cache"].is_mutable_file.return_value = False
response = client.get("/api/v1/remote/helm-test/vault-0.29.1.tgz")
assert response.status_code == 200
assert "application/gzip" in response.headers["content-type"]
assert response.headers["X-Artifact-Source"] == "cache"
def test_index_yaml_cache_miss_fetches_upstream(self, client, patched_deps):
deps = patched_deps
index = b"apiVersion: v1\nentries:\n vault:\n - urls:\n - https://helm.releases.hashicorp.com/vault-0.29.1.tgz\n"
deps["storage"].exists.return_value = False
deps["storage"].download_object.return_value = index
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/helm-test/index.yaml")
mock_fetch.assert_called_once()
assert response.status_code == 200
assert b"helm.releases.hashicorp.com" not in response.content
def test_non_tgz_non_yaml_path_blocked_by_pattern(self, client, patched_deps):
"""Paths that don't match immutable_patterns and aren't mutable are blocked."""
deps = patched_deps
deps["cache"].is_mutable_file.return_value = False
response = client.get("/api/v1/remote/helm-test/vault.zip")
assert response.status_code == 403