Compare commits

..

1 Commits

Author SHA1 Message Date
unkinben 8320987121 feat: add virtual repository support for unified index merging
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/tag/docker Pipeline was successful
Adds a new virtual repo type that merges indexes from multiple member remotes
of the same package type. Currently supports helm (index.yaml merge with URL
rewriting). Member fetches run in parallel; merged index is Redis-cached at
min(mutable_ttl) across members.
2026-04-29 22:59:51 +10:00
18 changed files with 286 additions and 170 deletions
+22 -25
View File
@@ -70,11 +70,10 @@ src/artifactapi/
| Method | Path | Description | | Method | Path | Description |
|---|---|---| |---|---|---|
| `GET` | `/api/v1/remote/{remote}/{path}` | Fetch artifact (auto-cache on miss) | | `GET` | `/api/v1/remote/{remote}/{path}` | Fetch artifact (auto-cache on miss) |
| `PUT` | `/api/v1/remote/{remote}/{path}` | Upload to local remote |
| `HEAD` | `/api/v1/remote/{remote}/{path}` | Check existence (local remotes) |
| `DELETE` | `/api/v1/remote/{remote}/{path}` | Delete from local remote |
| `GET` | `/api/v1/virtual/{virtual}/{path}` | Fetch from virtual (merged) repository | | `GET` | `/api/v1/virtual/{virtual}/{path}` | Fetch from virtual (merged) repository |
| `GET` | `/api/v1/local/{local}/{path}` | Download from local repository |
| `PUT` | `/api/v1/local/{local}/{path}` | Upload to local repository |
| `HEAD` | `/api/v1/local/{local}/{path}` | Check existence (local) |
| `DELETE` | `/api/v1/local/{local}/{path}` | Delete from local repository |
| `GET` | `/v2/{remote}/{path}` | Docker Registry v2 proxy | | `GET` | `/v2/{remote}/{path}` | Docker Registry v2 proxy |
| `PUT` | `/cache/flush` | Flush cache entries | | `PUT` | `/cache/flush` | Flush cache entries |
| `GET` | `/health` | Health check | | `GET` | `/health` | Health check |
@@ -121,14 +120,13 @@ config_dir: conf.d # or an absolute path
remotes: {} # optional base remotes remotes: {} # optional base remotes
``` ```
### Configuration structure ### remotes.yaml Structure
Repositories are declared under three top-level keys matching their type:
```yaml ```yaml
remotes: # proxy (caching) remotes remotes:
remote-name: remote-name:
base_url: "https://example.com" base_url: "https://example.com"
type: "remote" # "remote", "local", or "virtual"
package: "generic" # generic, alpine, rpm, docker, pypi, npm, helm package: "generic" # generic, alpine, rpm, docker, pypi, npm, helm
description: "..." description: "..."
immutable_patterns: # regex — cached forever immutable_patterns: # regex — cached forever
@@ -139,20 +137,6 @@ remotes: # proxy (caching) remotes
cache: cache:
immutable_ttl: 0 # 0 = indefinitely immutable_ttl: 0 # 0 = indefinitely
mutable_ttl: 3600 mutable_ttl: 3600
virtuals: # virtual (merged-index) repositories
virtual-name:
package: "helm"
members:
- remote-a
- remote-b
locals: # local upload repositories (no base_url)
local-name:
package: "generic"
cache:
immutable_ttl: 0
mutable_ttl: 0
``` ```
## Remote Types ## Remote Types
@@ -165,6 +149,7 @@ Arbitrary HTTP file servers — GitHub releases, HashiCorp, custom servers.
remotes: remotes:
github: github:
base_url: "https://github.com" base_url: "https://github.com"
type: "remote"
package: "generic" package: "generic"
immutable_patterns: immutable_patterns:
- "gruntwork-io/terragrunt/.*terragrunt_linux_amd64.*" - "gruntwork-io/terragrunt/.*terragrunt_linux_amd64.*"
@@ -173,6 +158,7 @@ remotes:
github-archive: github-archive:
base_url: "https://github.com" base_url: "https://github.com"
type: "remote"
package: "generic" package: "generic"
immutable_patterns: immutable_patterns:
- ".*/archive/refs/tags/.*\\.tar\\.gz$" # tag archives never change - ".*/archive/refs/tags/.*\\.tar\\.gz$" # tag archives never change
@@ -192,6 +178,7 @@ Access: `GET /api/v1/remote/github/owner/repo/releases/download/v1.0/binary.tar.
remotes: remotes:
alpine: alpine:
base_url: "https://dl-cdn.alpinelinux.org" base_url: "https://dl-cdn.alpinelinux.org"
type: "remote"
package: "alpine" package: "alpine"
immutable_patterns: immutable_patterns:
- ".*/x86_64/.*\\.apk$" - ".*/x86_64/.*\\.apk$"
@@ -208,6 +195,7 @@ remotes:
remotes: remotes:
almalinux: almalinux:
base_url: "https://mirror.example.com/almalinux" base_url: "https://mirror.example.com/almalinux"
type: "remote"
package: "rpm" package: "rpm"
immutable_patterns: immutable_patterns:
- ".*/x86_64/.*\\.rpm$" - ".*/x86_64/.*\\.rpm$"
@@ -225,6 +213,7 @@ remotes:
remotes: remotes:
dockerhub: dockerhub:
base_url: "https://registry-1.docker.io" base_url: "https://registry-1.docker.io"
type: "remote"
package: "docker" package: "docker"
# username / password optional for public images # username / password optional for public images
cache: cache:
@@ -233,6 +222,7 @@ remotes:
ghcr: ghcr:
base_url: "https://ghcr.io" base_url: "https://ghcr.io"
type: "remote"
package: "docker" package: "docker"
username: "your-github-username" username: "your-github-username"
password: "ghp_your_pat" # read:packages scope password: "ghp_your_pat" # read:packages scope
@@ -265,6 +255,7 @@ mirrors:
remotes: remotes:
pypi: pypi:
base_url: "https://files.pythonhosted.org" base_url: "https://files.pythonhosted.org"
type: "remote"
package: "pypi" package: "pypi"
check_mutable_updates: true check_mutable_updates: true
immutable_patterns: immutable_patterns:
@@ -296,6 +287,7 @@ default = true
remotes: remotes:
npm: npm:
base_url: "https://registry.npmjs.org" base_url: "https://registry.npmjs.org"
type: "remote"
package: "npm" package: "npm"
check_mutable_updates: true check_mutable_updates: true
immutable_patterns: immutable_patterns:
@@ -322,6 +314,7 @@ registry=https://artifacts.example.com/api/v1/remote/npm/
remotes: remotes:
hashicorp-helm: hashicorp-helm:
base_url: "https://helm.releases.hashicorp.com" base_url: "https://helm.releases.hashicorp.com"
type: "remote"
package: "helm" package: "helm"
check_mutable_updates: true check_mutable_updates: true
immutable_patterns: immutable_patterns:
@@ -350,6 +343,7 @@ All members must share the same `package` type as the virtual repo. Currently su
remotes: remotes:
helm-hashicorp: helm-hashicorp:
base_url: "https://helm.releases.hashicorp.com" base_url: "https://helm.releases.hashicorp.com"
type: "remote"
package: "helm" package: "helm"
immutable_patterns: immutable_patterns:
- "\\.tgz$" - "\\.tgz$"
@@ -359,6 +353,7 @@ remotes:
helm-bitnami: helm-bitnami:
base_url: "https://charts.bitnami.com/bitnami" base_url: "https://charts.bitnami.com/bitnami"
type: "remote"
package: "helm" package: "helm"
immutable_patterns: immutable_patterns:
- "\\.tgz$" - "\\.tgz$"
@@ -366,8 +361,8 @@ remotes:
immutable_ttl: 0 immutable_ttl: 0
mutable_ttl: 3600 mutable_ttl: 3600
virtuals:
helm-all: helm-all:
type: "virtual"
package: "helm" package: "helm"
members: members:
- helm-hashicorp # listed first = highest priority - helm-hashicorp # listed first = highest priority
@@ -404,8 +399,9 @@ Chart tarball URLs in the merged `index.yaml` are rewritten to point at the indi
### local ### local
```yaml ```yaml
locals: remotes:
local-generic: local-generic:
type: "local"
package: "generic" package: "generic"
description: "Local file repository" description: "Local file repository"
cache: cache:
@@ -413,7 +409,7 @@ locals:
mutable_ttl: 0 mutable_ttl: 0
``` ```
No `base_url`. Files are uploaded via `PUT /api/v1/local/{name}/{path}` and downloaded via `GET /api/v1/local/{name}/{path}`. No `base_url`. Files are uploaded via `PUT` and served via `GET`.
## Caching Model ## Caching Model
@@ -455,6 +451,7 @@ Set `quarantine_new: true` and `quarantine_days: N` on a remote to block immutab
remotes: remotes:
pypi: pypi:
base_url: "https://files.pythonhosted.org" base_url: "https://files.pythonhosted.org"
type: "remote"
package: "pypi" package: "pypi"
quarantine_new: true quarantine_new: true
quarantine_days: 3 # block packages published in the last 3 days quarantine_days: 3 # block packages published in the last 3 days
+11
View File
@@ -0,0 +1,11 @@
remotes:
alpine:
base_url: "https://dl-cdn.alpinelinux.org"
type: "remote"
package: "alpine"
description: "Alpine Linux APK package repository"
immutable_patterns:
- ".*/x86_64/.*\\.apk$"
cache:
immutable_ttl: 0
mutable_ttl: 7200
+12
View File
@@ -0,0 +1,12 @@
remotes:
github:
base_url: "https://github.com"
type: "remote"
package: "generic"
description: "GitHub releases and files"
immutable_patterns:
- "gruntwork-io/terragrunt/.*terragrunt_linux_amd64.*"
- "prometheus/node_exporter/.*/node_exporter-.*\\.linux-amd64\\.tar\\.gz$"
cache:
immutable_ttl: 0
mutable_ttl: 0
+17
View File
@@ -0,0 +1,17 @@
remotes:
pypi:
base_url: "https://files.pythonhosted.org"
type: "remote"
package: "pypi"
description: "Python Package Index"
check_mutable_updates: true
quarantine_new: true
quarantine_days: 3
immutable_patterns:
- "packages/.*\\.whl$"
- "packages/.*\\.whl\\.metadata$"
- "packages/.*\\.tar\\.gz$"
- "packages/.*\\.zip$"
cache:
immutable_ttl: 0
mutable_ttl: 600
+1
View File
@@ -1,6 +1,7 @@
remotes: remotes:
alpine: alpine:
base_url: "https://dl-cdn.alpinelinux.org" base_url: "https://dl-cdn.alpinelinux.org"
type: "remote"
package: "alpine" package: "alpine"
description: "Alpine Linux APK package repository" description: "Alpine Linux APK package repository"
immutable_patterns: immutable_patterns:
+1
View File
@@ -1,6 +1,7 @@
remotes: remotes:
github: github:
base_url: "https://github.com" base_url: "https://github.com"
type: "remote"
package: "generic" package: "generic"
description: "GitHub releases and files" description: "GitHub releases and files"
immutable_patterns: immutable_patterns:
+1
View File
@@ -1,6 +1,7 @@
remotes: remotes:
pypi: pypi:
base_url: "https://files.pythonhosted.org" base_url: "https://files.pythonhosted.org"
type: "remote"
package: "pypi" package: "pypi"
description: "Python Package Index" description: "Python Package Index"
check_mutable_updates: true check_mutable_updates: true
+98 -35
View File
@@ -34,7 +34,9 @@
# #
remotes: remotes:
github: github:
base_url: "https://github.com" package: "generic" base_url: "https://github.com"
type: "remote"
package: "generic"
description: "GitHub releases and files" description: "GitHub releases and files"
immutable_patterns: immutable_patterns:
- "gruntwork-io/terragrunt/.*terragrunt_linux_amd64.*" - "gruntwork-io/terragrunt/.*terragrunt_linux_amd64.*"
@@ -64,7 +66,9 @@ remotes:
mutable_ttl: 0 mutable_ttl: 0
github-archive: github-archive:
base_url: "https://github.com" package: "generic" base_url: "https://github.com"
type: "remote"
package: "generic"
description: "GitHub repository archive tarballs" description: "GitHub repository archive tarballs"
immutable_patterns: immutable_patterns:
# Tag archives are immutable — a tag never changes # Tag archives are immutable — a tag never changes
@@ -82,7 +86,9 @@ remotes:
mutable_ttl: 86400 # Branch archives refreshed after 1 day mutable_ttl: 86400 # Branch archives refreshed after 1 day
gitea-dl: gitea-dl:
base_url: "https://dl.gitea.com" package: "generic" base_url: "https://dl.gitea.com"
type: "remote"
package: "generic"
description: "Gitea download site" description: "Gitea download site"
immutable_patterns: immutable_patterns:
- "act_runner/.*/act_runner-.*-linux-amd64$" - "act_runner/.*/act_runner-.*-linux-amd64$"
@@ -91,7 +97,9 @@ remotes:
mutable_ttl: 0 mutable_ttl: 0
hashicorp-releases: hashicorp-releases:
base_url: "https://releases.hashicorp.com" package: "generic" base_url: "https://releases.hashicorp.com"
type: "remote"
package: "generic"
description: "HashiCorp product releases" description: "HashiCorp product releases"
immutable_patterns: immutable_patterns:
- "terraform/.*terraform_.*_linux_amd64\\.zip$" - "terraform/.*terraform_.*_linux_amd64\\.zip$"
@@ -110,7 +118,9 @@ remotes:
mutable_ttl: 0 mutable_ttl: 0
alpine: alpine:
base_url: "https://dl-cdn.alpinelinux.org" package: "alpine" base_url: "https://dl-cdn.alpinelinux.org"
type: "remote"
package: "alpine"
description: "Alpine Linux APK package repository" description: "Alpine Linux APK package repository"
immutable_patterns: immutable_patterns:
- ".*/x86_64/.*\\.apk$" - ".*/x86_64/.*\\.apk$"
@@ -122,7 +132,9 @@ remotes:
mutable_ttl: 7200 # Index files (APKINDEX.tar.gz) cached for 2 hours mutable_ttl: 7200 # Index files (APKINDEX.tar.gz) cached for 2 hours
almalinux: almalinux:
base_url: "https://gsl-syd.mm.fcix.net/almalinux" package: "rpm" base_url: "https://gsl-syd.mm.fcix.net/almalinux"
type: "remote"
package: "rpm"
description: "AlmaLinux RPM package repository" description: "AlmaLinux RPM package repository"
immutable_patterns: immutable_patterns:
- ".*/x86_64/.*\\.rpm$" - ".*/x86_64/.*\\.rpm$"
@@ -137,7 +149,9 @@ remotes:
mutable_ttl: 7200 # Metadata files cached for 2 hours mutable_ttl: 7200 # Metadata files cached for 2 hours
epel: epel:
base_url: "http://mirror.aarnet.edu.au/pub/epel" package: "rpm" base_url: "http://mirror.aarnet.edu.au/pub/epel"
type: "remote"
package: "rpm"
description: "EPEL (Extra Packages for Enterprise Linux)" description: "EPEL (Extra Packages for Enterprise Linux)"
immutable_patterns: immutable_patterns:
- "8/Everything/x86_64/.*\\.rpm$" - "8/Everything/x86_64/.*\\.rpm$"
@@ -150,7 +164,9 @@ remotes:
mutable_ttl: 7200 # Metadata files cached for 2 hours mutable_ttl: 7200 # Metadata files cached for 2 hours
fedora: fedora:
base_url: "https://gsl-syd.mm.fcix.net/fedora/linux" package: "rpm" base_url: "https://gsl-syd.mm.fcix.net/fedora/linux"
type: "remote"
package: "rpm"
description: "Fedora Linux RPM package repository" description: "Fedora Linux RPM package repository"
immutable_patterns: immutable_patterns:
- "releases/.*/Everything/x86_64/.*\\.rpm$" - "releases/.*/Everything/x86_64/.*\\.rpm$"
@@ -163,7 +179,9 @@ remotes:
mutable_ttl: 300 # Metadata files cached for 5 minutes mutable_ttl: 300 # Metadata files cached for 5 minutes
ghcr: ghcr:
base_url: "https://ghcr.io" package: "docker" base_url: "https://ghcr.io"
type: "remote"
package: "docker"
description: "GitHub Container Registry" description: "GitHub Container Registry"
# username: "your-github-username" # username: "your-github-username"
# password: "your-github-pat" # needs read:packages scope # password: "your-github-pat" # needs read:packages scope
@@ -175,14 +193,18 @@ remotes:
mutable_ttl: 300 mutable_ttl: 300
dockerhub: dockerhub:
base_url: "https://registry-1.docker.io" package: "docker" base_url: "https://registry-1.docker.io"
type: "remote"
package: "docker"
description: "Docker Hub registry" description: "Docker Hub registry"
cache: cache:
immutable_ttl: 0 immutable_ttl: 0
mutable_ttl: 300 mutable_ttl: 300
pypi: pypi:
base_url: "https://files.pythonhosted.org" package: "pypi" base_url: "https://files.pythonhosted.org"
type: "remote"
package: "pypi"
description: "Python Package Index — simple index and package files via a single remote" 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 # 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. # files.pythonhosted.org (base_url). URLs in the simple index are rewritten to this remote.
@@ -203,7 +225,9 @@ remotes:
mutable_ttl: 600 # Simple index pages refreshed after 10 minutes mutable_ttl: 600 # Simple index pages refreshed after 10 minutes
pypi-gitea: pypi-gitea:
base_url: "https://gitea.example.com/api/packages/myorg/pypi" package: "pypi" 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" description: "Private Gitea PyPI registry — simple index and files at the same host"
# username: "your-gitea-username" # username: "your-gitea-username"
# password: "your-personal-access-token" # needs package:read scope # password: "your-personal-access-token" # needs package:read scope
@@ -219,7 +243,9 @@ remotes:
mutable_ttl: 600 mutable_ttl: 600
npm: npm:
base_url: "https://registry.npmjs.org" package: "npm" base_url: "https://registry.npmjs.org"
type: "remote"
package: "npm"
description: "npm registry — package metadata with tarball URL rewriting" description: "npm registry — package metadata with tarball URL rewriting"
check_mutable_updates: true check_mutable_updates: true
immutable_patterns: immutable_patterns:
@@ -231,7 +257,9 @@ remotes:
mutable_ttl: 600 # Package metadata refreshed after 10 minutes mutable_ttl: 600 # Package metadata refreshed after 10 minutes
hashicorp-helm: hashicorp-helm:
base_url: "https://helm.releases.hashicorp.com" package: "helm" base_url: "https://helm.releases.hashicorp.com"
type: "remote"
package: "helm"
description: "HashiCorp Helm chart repository (Vault, Consul, Nomad, etc.)" description: "HashiCorp Helm chart repository (Vault, Consul, Nomad, etc.)"
check_mutable_updates: true check_mutable_updates: true
immutable_patterns: immutable_patterns:
@@ -241,7 +269,9 @@ remotes:
mutable_ttl: 3600 # index.yaml refreshed after 1 hour mutable_ttl: 3600 # index.yaml refreshed after 1 hour
metallb: metallb:
base_url: "https://metallb.github.io/metallb" package: "helm" base_url: "https://metallb.github.io/metallb"
type: "remote"
package: "helm"
description: "MetalLB load balancer Helm charts" description: "MetalLB load balancer Helm charts"
check_mutable_updates: true check_mutable_updates: true
immutable_patterns: immutable_patterns:
@@ -251,7 +281,9 @@ remotes:
mutable_ttl: 3600 mutable_ttl: 3600
jetstack: jetstack:
base_url: "https://charts.jetstack.io" package: "helm" base_url: "https://charts.jetstack.io"
type: "remote"
package: "helm"
description: "Jetstack Helm charts (cert-manager)" description: "Jetstack Helm charts (cert-manager)"
check_mutable_updates: true check_mutable_updates: true
immutable_patterns: immutable_patterns:
@@ -261,7 +293,9 @@ remotes:
mutable_ttl: 3600 mutable_ttl: 3600
rancher-stable: rancher-stable:
base_url: "https://releases.rancher.com/server-charts/stable" package: "helm" base_url: "https://releases.rancher.com/server-charts/stable"
type: "remote"
package: "helm"
description: "Rancher stable Helm charts" description: "Rancher stable Helm charts"
check_mutable_updates: true check_mutable_updates: true
immutable_patterns: immutable_patterns:
@@ -271,7 +305,9 @@ remotes:
mutable_ttl: 3600 mutable_ttl: 3600
purelb: purelb:
base_url: "https://gitlab.com/api/v4/projects/20400619/packages/helm/stable" package: "helm" base_url: "https://gitlab.com/api/v4/projects/20400619/packages/helm/stable"
type: "remote"
package: "helm"
description: "PureLB load balancer Helm charts" description: "PureLB load balancer Helm charts"
check_mutable_updates: true check_mutable_updates: true
immutable_patterns: immutable_patterns:
@@ -281,7 +317,9 @@ remotes:
mutable_ttl: 3600 mutable_ttl: 3600
istio: istio:
base_url: "https://istio-release.storage.googleapis.com/charts" package: "helm" base_url: "https://istio-release.storage.googleapis.com/charts"
type: "remote"
package: "helm"
description: "Istio service mesh Helm charts" description: "Istio service mesh Helm charts"
check_mutable_updates: true check_mutable_updates: true
immutable_patterns: immutable_patterns:
@@ -291,7 +329,9 @@ remotes:
mutable_ttl: 3600 mutable_ttl: 3600
cnpg: cnpg:
base_url: "https://cloudnative-pg.github.io/charts" package: "helm" base_url: "https://cloudnative-pg.github.io/charts"
type: "remote"
package: "helm"
description: "CloudNativePG operator Helm charts" description: "CloudNativePG operator Helm charts"
check_mutable_updates: true check_mutable_updates: true
immutable_patterns: immutable_patterns:
@@ -301,7 +341,9 @@ remotes:
mutable_ttl: 3600 mutable_ttl: 3600
ceph-csi: ceph-csi:
base_url: "https://ceph.github.io/csi-charts" package: "helm" base_url: "https://ceph.github.io/csi-charts"
type: "remote"
package: "helm"
description: "Ceph CSI driver Helm charts" description: "Ceph CSI driver Helm charts"
check_mutable_updates: true check_mutable_updates: true
immutable_patterns: immutable_patterns:
@@ -311,7 +353,9 @@ remotes:
mutable_ttl: 3600 mutable_ttl: 3600
external-dns: external-dns:
base_url: "https://kubernetes-sigs.github.io/external-dns/" package: "helm" base_url: "https://kubernetes-sigs.github.io/external-dns/"
type: "remote"
package: "helm"
description: "ExternalDNS Helm charts" description: "ExternalDNS Helm charts"
check_mutable_updates: true check_mutable_updates: true
immutable_patterns: immutable_patterns:
@@ -321,7 +365,9 @@ remotes:
mutable_ttl: 3600 mutable_ttl: 3600
intel-helm: intel-helm:
base_url: "https://intel.github.io/helm-charts/" package: "helm" base_url: "https://intel.github.io/helm-charts/"
type: "remote"
package: "helm"
description: "Intel Helm charts" description: "Intel Helm charts"
check_mutable_updates: true check_mutable_updates: true
immutable_patterns: immutable_patterns:
@@ -331,7 +377,9 @@ remotes:
mutable_ttl: 3600 mutable_ttl: 3600
elastic: elastic:
base_url: "https://helm.elastic.co" package: "helm" base_url: "https://helm.elastic.co"
type: "remote"
package: "helm"
description: "Elastic stack Helm charts" description: "Elastic stack Helm charts"
check_mutable_updates: true check_mutable_updates: true
immutable_patterns: immutable_patterns:
@@ -341,7 +389,9 @@ remotes:
mutable_ttl: 3600 mutable_ttl: 3600
k8up-io: k8up-io:
base_url: "https://k8up-io.github.io/k8up" package: "helm" base_url: "https://k8up-io.github.io/k8up"
type: "remote"
package: "helm"
description: "K8up backup operator Helm charts" description: "K8up backup operator Helm charts"
check_mutable_updates: true check_mutable_updates: true
immutable_patterns: immutable_patterns:
@@ -351,7 +401,9 @@ remotes:
mutable_ttl: 3600 mutable_ttl: 3600
victoriametrics: victoriametrics:
base_url: "https://victoriametrics.github.io/helm-charts/" package: "helm" base_url: "https://victoriametrics.github.io/helm-charts/"
type: "remote"
package: "helm"
description: "VictoriaMetrics observability Helm charts" description: "VictoriaMetrics observability Helm charts"
check_mutable_updates: true check_mutable_updates: true
immutable_patterns: immutable_patterns:
@@ -361,7 +413,9 @@ remotes:
mutable_ttl: 3600 mutable_ttl: 3600
grafana: grafana:
base_url: "https://grafana.github.io/helm-charts" package: "helm" base_url: "https://grafana.github.io/helm-charts"
type: "remote"
package: "helm"
description: "Grafana observability Helm charts" description: "Grafana observability Helm charts"
check_mutable_updates: true check_mutable_updates: true
immutable_patterns: immutable_patterns:
@@ -371,7 +425,9 @@ remotes:
mutable_ttl: 3600 mutable_ttl: 3600
helm-openldap: helm-openldap:
base_url: "https://jp-gouin.github.io/helm-openldap/" package: "helm" base_url: "https://jp-gouin.github.io/helm-openldap/"
type: "remote"
package: "helm"
description: "OpenLDAP Helm charts" description: "OpenLDAP Helm charts"
check_mutable_updates: true check_mutable_updates: true
immutable_patterns: immutable_patterns:
@@ -381,7 +437,9 @@ remotes:
mutable_ttl: 3600 mutable_ttl: 3600
woodpecker: woodpecker:
base_url: "https://woodpecker-ci.org/" package: "helm" base_url: "https://woodpecker-ci.org/"
type: "remote"
package: "helm"
description: "Woodpecker CI Helm charts" description: "Woodpecker CI Helm charts"
check_mutable_updates: true check_mutable_updates: true
immutable_patterns: immutable_patterns:
@@ -391,7 +449,9 @@ remotes:
mutable_ttl: 3600 mutable_ttl: 3600
stakater: stakater:
base_url: "https://stakater.github.io/stakater-charts" package: "helm" base_url: "https://stakater.github.io/stakater-charts"
type: "remote"
package: "helm"
description: "Stakater Helm charts" description: "Stakater Helm charts"
check_mutable_updates: true check_mutable_updates: true
immutable_patterns: immutable_patterns:
@@ -401,7 +461,9 @@ remotes:
mutable_ttl: 3600 mutable_ttl: 3600
jfrog: jfrog:
base_url: "https://charts.jfrog.io/" package: "helm" base_url: "https://charts.jfrog.io/"
type: "remote"
package: "helm"
description: "JFrog Helm charts" description: "JFrog Helm charts"
check_mutable_updates: true check_mutable_updates: true
immutable_patterns: immutable_patterns:
@@ -411,7 +473,9 @@ remotes:
mutable_ttl: 3600 mutable_ttl: 3600
openvox: openvox:
base_url: "https://openvoxproject.github.io/openvox-helm-chart" package: "helm" base_url: "https://openvoxproject.github.io/openvox-helm-chart"
type: "remote"
package: "helm"
description: "OpenVox Helm charts" description: "OpenVox Helm charts"
check_mutable_updates: true check_mutable_updates: true
immutable_patterns: immutable_patterns:
@@ -420,9 +484,8 @@ remotes:
immutable_ttl: 0 immutable_ttl: 0
mutable_ttl: 3600 mutable_ttl: 3600
virtuals:
helm-all: helm-all:
type: "virtual"
package: "helm" package: "helm"
description: "Virtual repository merging all helm remotes — member order is priority order for duplicate chart+version" description: "Virtual repository merging all helm remotes — member order is priority order for duplicate chart+version"
members: members:
@@ -446,8 +509,8 @@ virtuals:
- jfrog - jfrog
- openvox - openvox
locals:
local-generic: local-generic:
type: "local"
package: "generic" package: "generic"
description: "Local generic file repository" description: "Local generic file repository"
cache: cache:
+16 -21
View File
@@ -1,6 +1,5 @@
import hashlib import hashlib
import logging import logging
import os
from fastapi import HTTPException, Response, UploadFile from fastapi import HTTPException, Response, UploadFile
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
@@ -8,23 +7,12 @@ from fastapi.responses import JSONResponse
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def download(remote_name: str, path: str, storage, database, config) -> Response:
if not config.get_local_config(remote_name):
raise HTTPException(status_code=404, detail=f"Local repository '{remote_name}' not configured")
metadata = database.get_local_file_metadata(remote_name, path)
if not metadata:
raise HTTPException(status_code=404, detail="File not found")
content = storage.download_object(metadata["s3_key"])
return Response(
content=content,
media_type=metadata.get("content_type", "application/octet-stream"),
headers={"Content-Disposition": f"attachment; filename={os.path.basename(path)}"},
)
async def upload(remote_name: str, path: str, file: UploadFile, storage, database, config) -> JSONResponse: async def upload(remote_name: str, path: str, file: UploadFile, storage, database, config) -> JSONResponse:
if not config.get_local_config(remote_name): remote_config = config.get_remote_config(remote_name)
raise HTTPException(status_code=404, detail=f"Local repository '{remote_name}' not configured") if not remote_config:
raise HTTPException(status_code=404, detail=f"Remote '{remote_name}' not configured")
if remote_config.get("type") != "local":
raise HTTPException(status_code=400, detail="Upload only supported for local repositories")
try: try:
content = await file.read() content = await file.read()
@@ -71,8 +59,12 @@ async def upload(remote_name: str, path: str, file: UploadFile, storage, databas
def check_exists(remote_name: str, path: str, database, config) -> Response: def check_exists(remote_name: str, path: str, database, config) -> Response:
if not config.get_local_config(remote_name): remote_config = config.get_remote_config(remote_name)
raise HTTPException(status_code=404, detail=f"Local repository '{remote_name}' not configured") if not remote_config:
raise HTTPException(status_code=404, detail=f"Remote '{remote_name}' not configured")
if remote_config.get("type") != "local":
raise HTTPException(status_code=405, detail="HEAD method only supported for local repositories")
try: try:
metadata = database.get_local_file_metadata(remote_name, path) metadata = database.get_local_file_metadata(remote_name, path)
@@ -95,8 +87,11 @@ def check_exists(remote_name: str, path: str, database, config) -> Response:
def delete(remote_name: str, path: str, storage, database, config) -> JSONResponse: def delete(remote_name: str, path: str, storage, database, config) -> JSONResponse:
if not config.get_local_config(remote_name): remote_config = config.get_remote_config(remote_name)
raise HTTPException(status_code=404, detail=f"Local repository '{remote_name}' not configured") if not remote_config:
raise HTTPException(status_code=404, detail=f"Remote '{remote_name}' not configured")
if remote_config.get("type") != "local":
raise HTTPException(status_code=400, detail="Delete only supported for local repositories")
try: try:
s3_key = database.delete_local_file(remote_name, path) s3_key = database.delete_local_file(remote_name, path)
+13
View File
@@ -218,6 +218,19 @@ async def handle(request: Request, remote_name: str, path: str, storage, cache,
if not remote_config: if not remote_config:
raise HTTPException(status_code=404, detail=f"Remote '{remote_name}' not configured") raise HTTPException(status_code=404, detail=f"Remote '{remote_name}' not configured")
if remote_config.get("type") == "local":
metadata = database.get_local_file_metadata(remote_name, path)
if not metadata:
raise HTTPException(status_code=404, detail="File not found")
content = storage.download_object(metadata["s3_key"])
if content is None:
raise HTTPException(status_code=500, detail="File not accessible")
return Response(
content=content,
media_type=metadata.get("content_type", "application/octet-stream"),
headers={"Content-Disposition": f"attachment; filename={os.path.basename(path)}"},
)
path_parts = path.split("/") path_parts = path.split("/")
if len(path_parts) >= 2: if len(path_parts) >= 2:
repo_path = f"{path_parts[0]}/{path_parts[1]}" repo_path = f"{path_parts[0]}/{path_parts[1]}"
+3 -1
View File
@@ -147,9 +147,11 @@ _HANDLERS: dict[str, _VirtualHandler] = {
async def handle(request: Request, virtual_name: str, path: str, storage, cache, config) -> Response: async def handle(request: Request, virtual_name: str, path: str, storage, cache, config) -> Response:
virtual_cfg = config.get_virtual_config(virtual_name) virtual_cfg = config.get_remote_config(virtual_name)
if not virtual_cfg: if not virtual_cfg:
raise HTTPException(status_code=404, detail=f"Virtual repository '{virtual_name}' not configured") raise HTTPException(status_code=404, detail=f"Virtual repository '{virtual_name}' not configured")
if virtual_cfg.get("type") != "virtual":
raise HTTPException(status_code=400, detail=f"'{virtual_name}' is not a virtual repository")
package = virtual_cfg.get("package") package = virtual_cfg.get("package")
handler = _HANDLERS.get(package) handler = _HANDLERS.get(package)
+4 -12
View File
@@ -50,8 +50,8 @@ class ConfigManager:
def _merge(base: dict, overlay: dict) -> dict: def _merge(base: dict, overlay: dict) -> dict:
result = {**base} result = {**base}
for key, value in overlay.items(): for key, value in overlay.items():
if key in ("remotes", "virtuals", "locals") and isinstance(base.get(key), dict) and isinstance(value, dict): if key == "remotes" and isinstance(base.get("remotes"), dict) and isinstance(value, dict):
result[key] = {**base.get(key, {}), **value} result["remotes"] = {**base.get("remotes", {}), **value}
else: else:
result[key] = value result[key] = value
return result return result
@@ -67,11 +67,11 @@ class ConfigManager:
self._config_dir = None self._config_dir = None
if os.path.isdir(self.config_path): if os.path.isdir(self.config_path):
return self._load_from_dir(self.config_path) or {"remotes": {}, "virtuals": {}, "locals": {}} return self._load_from_dir(self.config_path) or {"remotes": {}}
config = self._load_single_file(self.config_path) config = self._load_single_file(self.config_path)
if not config: if not config:
return {"remotes": {}, "virtuals": {}, "locals": {}} return {"remotes": {}}
config_dir = config.pop("config_dir", None) config_dir = config.pop("config_dir", None)
if config_dir: if config_dir:
@@ -119,14 +119,6 @@ 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_virtual_config(self, virtual_name: str) -> dict | None:
self._check_reload()
return self.config.get("virtuals", {}).get(virtual_name)
def get_local_config(self, local_name: str) -> dict | None:
self._check_reload()
return self.config.get("locals", {}).get(local_name)
def get_immutable_patterns(self, remote_name: str, repo_path: str = "") -> list[str]: 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:
+10 -21
View File
@@ -49,13 +49,7 @@ class ArtifactRequest(BaseModel):
@app.get("/") @app.get("/")
def read_root(): def read_root():
config._check_reload() config._check_reload()
return { return {"message": "Artifact Storage API", "version": app.version, "remotes": list(config.config.get("remotes", {}).keys())}
"message": "Artifact Storage API",
"version": app.version,
"remotes": list(config.config.get("remotes", {}).keys()),
"virtuals": list(config.config.get("virtuals", {}).keys()),
"locals": list(config.config.get("locals", {}).keys()),
}
@app.get("/health") @app.get("/health")
@@ -105,24 +99,19 @@ async def get_artifact(request: Request, remote_name: str, path: str):
return await proxy.handle(request, remote_name, path, storage, cache, config, database, metrics) return await proxy.handle(request, remote_name, path, storage, cache, config, database, metrics)
@app.get("/api/v1/local/{local_name}/{path:path}") @app.put("/api/v1/remote/{remote_name}/{path:path}")
def get_local_artifact(local_name: str, path: str): async def upload_file(remote_name: str, path: str, file: UploadFile = File(...)):
return local.download(local_name, path, storage, database, config) return await local.upload(remote_name, path, file, storage, database, config)
@app.put("/api/v1/local/{local_name}/{path:path}") @app.head("/api/v1/remote/{remote_name}/{path:path}")
async def upload_local_file(local_name: str, path: str, file: UploadFile = File(...)): def check_file_exists(remote_name: str, path: str):
return await local.upload(local_name, path, file, storage, database, config) return local.check_exists(remote_name, path, database, config)
@app.head("/api/v1/local/{local_name}/{path:path}") @app.delete("/api/v1/remote/{remote_name}/{path:path}")
def check_local_file_exists(local_name: str, path: str): def delete_file(remote_name: str, path: str):
return local.check_exists(local_name, path, database, config) return local.delete(remote_name, path, storage, database, config)
@app.delete("/api/v1/local/{local_name}/{path:path}")
def delete_local_file(local_name: str, path: str):
return local.delete(local_name, path, storage, database, config)
@app.post("/api/v1/artifacts/cache") @app.post("/api/v1/artifacts/cache")
+7 -13
View File
@@ -87,10 +87,9 @@ class MetricsManager:
# Get from database if available # Get from database if available
db_sizes = self.database_manager.get_storage_by_remote() db_sizes = self.database_manager.get_storage_by_remote()
if db_sizes: if db_sizes:
# Initialize all configured remotes and locals to 0 # Initialize all configured remotes to 0
remote_sizes = {} remote_sizes = {}
all_names = list(config_manager.config.get("remotes", {}).keys()) + list(config_manager.config.get("locals", {}).keys()) for remote in config_manager.config.get("remotes", {}).keys():
for remote in all_names:
remote_sizes[remote] = db_sizes.get(remote, 0) remote_sizes[remote] = db_sizes.get(remote, 0)
# Update Prometheus gauges # Update Prometheus gauges
@@ -102,10 +101,10 @@ class MetricsManager:
# Fallback to S3 scanning if database not available # Fallback to S3 scanning if database not available
try: try:
remote_sizes = {} remote_sizes = {}
all_names = list(config_manager.config.get("remotes", {}).keys()) + list(config_manager.config.get("locals", {}).keys()) remotes = config_manager.config.get("remotes", {}).keys()
# Initialize all remotes and locals to 0 # Initialize all remotes to 0
for remote in all_names: for remote in remotes:
remote_sizes[remote] = 0 remote_sizes[remote] = 0
paginator = storage.client.get_paginator("list_objects_v2") paginator = storage.client.get_paginator("list_objects_v2")
@@ -175,13 +174,8 @@ class MetricsManager:
metrics["requests"]["cache_hit_ratio"] = cache_hits / total_requests if total_requests > 0 else 0.0 metrics["requests"]["cache_hit_ratio"] = cache_hits / total_requests if total_requests > 0 else 0.0
metrics["bandwidth"]["saved_bytes"] = bandwidth_saved metrics["bandwidth"]["saved_bytes"] = bandwidth_saved
# Get per-repo metrics # Get per-remote metrics
all_repos = { for remote in config_manager.config.get("remotes", {}).keys():
**config_manager.config.get("remotes", {}),
**config_manager.config.get("virtuals", {}),
**config_manager.config.get("locals", {}),
}
for remote in all_repos.keys():
remote_cache_hits = int(self.redis_client.client.get(f"metrics:cache_hits:{remote}") or 0) remote_cache_hits = int(self.redis_client.client.get(f"metrics:cache_hits:{remote}") or 0)
remote_cache_misses = int(self.redis_client.client.get(f"metrics:cache_misses:{remote}") or 0) remote_cache_misses = int(self.redis_client.client.get(f"metrics:cache_misses:{remote}") or 0)
remote_total = remote_cache_hits + remote_cache_misses remote_total = remote_cache_hits + remote_cache_misses
+22 -9
View File
@@ -20,48 +20,61 @@ TEST_REMOTES = {
"remotes": { "remotes": {
"alpine-test": { "alpine-test": {
"base_url": "https://dl-cdn.alpinelinux.org", "base_url": "https://dl-cdn.alpinelinux.org",
"type": "remote",
"package": "alpine", "package": "alpine",
"immutable_patterns": [".*/x86_64/.*\\.apk$"], "immutable_patterns": [".*/x86_64/.*\\.apk$"],
"cache": {"immutable_ttl": 0, "mutable_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",
"package": "rpm", "package": "rpm",
"immutable_patterns": [".*/x86_64/.*\\.rpm$", ".*/repodata/.*$"], "immutable_patterns": [".*/x86_64/.*\\.rpm$", ".*/repodata/.*$"],
"cache": {"immutable_ttl": 0, "mutable_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",
"package": "docker", "package": "docker",
"cache": {"immutable_ttl": 0, "mutable_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",
"package": "docker", "package": "docker",
"immutable_patterns": ["^library/nginx"], "immutable_patterns": ["^library/nginx"],
"cache": {"immutable_ttl": 0, "mutable_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",
"package": "generic", "package": "generic",
"immutable_patterns": [".*\\.tar\\.gz$"], "immutable_patterns": [".*\\.tar\\.gz$"],
"cache": {"immutable_ttl": 0, "mutable_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",
"package": "generic", "package": "generic",
"mutable_patterns": ["metadata\\.json$"], "mutable_patterns": ["metadata\\.json$"],
"cache": {"immutable_ttl": 0, "mutable_ttl": 600}, "cache": {"immutable_ttl": 0, "mutable_ttl": 600},
}, },
"check-mutable-test": { "check-mutable-test": {
"base_url": "https://example.com", "base_url": "https://example.com",
"type": "remote",
"package": "generic", "package": "generic",
"mutable_patterns": ["metadata\\.json$"], "mutable_patterns": ["metadata\\.json$"],
"check_mutable_updates": True, "check_mutable_updates": True,
"cache": {"immutable_ttl": 0, "mutable_ttl": 600}, "cache": {"immutable_ttl": 0, "mutable_ttl": 600},
}, },
"local-test": {
"type": "local",
"package": "generic",
"cache": {"immutable_ttl": 0, "mutable_ttl": 0},
},
"pypi-test": { "pypi-test": {
"base_url": "https://files.pythonhosted.org", "base_url": "https://files.pythonhosted.org",
"type": "remote",
"package": "pypi", "package": "pypi",
"immutable_patterns": [ "immutable_patterns": [
r"packages/.*\.whl$", r"packages/.*\.whl$",
@@ -72,6 +85,7 @@ TEST_REMOTES = {
}, },
"npm-test": { "npm-test": {
"base_url": "https://registry.npmjs.org", "base_url": "https://registry.npmjs.org",
"type": "remote",
"package": "npm", "package": "npm",
"immutable_patterns": [r"\.tgz$"], "immutable_patterns": [r"\.tgz$"],
"mutable_patterns": [r"^(?!.*\.tgz$).*"], "mutable_patterns": [r"^(?!.*\.tgz$).*"],
@@ -79,12 +93,14 @@ TEST_REMOTES = {
}, },
"helm-test": { "helm-test": {
"base_url": "https://helm.releases.hashicorp.com", "base_url": "https://helm.releases.hashicorp.com",
"type": "remote",
"package": "helm", "package": "helm",
"immutable_patterns": [r"\.tgz$"], "immutable_patterns": [r"\.tgz$"],
"cache": {"immutable_ttl": 0, "mutable_ttl": 3600}, "cache": {"immutable_ttl": 0, "mutable_ttl": 3600},
}, },
"quarantine-test": { "quarantine-test": {
"base_url": "https://releases.example.com", "base_url": "https://releases.example.com",
"type": "remote",
"package": "generic", "package": "generic",
"immutable_patterns": [r".*\.tar\.gz$"], "immutable_patterns": [r".*\.tar\.gz$"],
"quarantine_new": True, "quarantine_new": True,
@@ -93,6 +109,7 @@ TEST_REMOTES = {
}, },
"quarantine-disabled": { "quarantine-disabled": {
"base_url": "https://releases.example.com", "base_url": "https://releases.example.com",
"type": "remote",
"package": "generic", "package": "generic",
"immutable_patterns": [r".*\.tar\.gz$"], "immutable_patterns": [r".*\.tar\.gz$"],
"quarantine_new": False, "quarantine_new": False,
@@ -101,31 +118,27 @@ TEST_REMOTES = {
}, },
"helm-member-2": { "helm-member-2": {
"base_url": "https://charts.example.com", "base_url": "https://charts.example.com",
"type": "remote",
"package": "helm", "package": "helm",
"immutable_patterns": [r"\.tgz$"], "immutable_patterns": [r"\.tgz$"],
"cache": {"immutable_ttl": 0, "mutable_ttl": 1800}, "cache": {"immutable_ttl": 0, "mutable_ttl": 1800},
}, },
},
"locals": {
"local-test": {
"package": "generic",
"cache": {"immutable_ttl": 0, "mutable_ttl": 0},
},
},
"virtuals": {
"helm-virtual-test": { "helm-virtual-test": {
"type": "virtual",
"package": "helm", "package": "helm",
"members": ["helm-test", "helm-member-2"], "members": ["helm-test", "helm-member-2"],
}, },
"unsupported-virtual-test": { "unsupported-virtual-test": {
"type": "virtual",
"package": "rpm", "package": "rpm",
"members": ["rpm-test"], "members": ["rpm-test"],
}, },
"empty-virtual-test": { "empty-virtual-test": {
"type": "virtual",
"package": "helm", "package": "helm",
"members": [], "members": [],
}, },
}, }
} }
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+19 -19
View File
@@ -27,24 +27,24 @@ def make_config(tmp_path):
class TestGetMutablePatterns: class TestGetMutablePatterns:
def test_alpine_returns_package_defaults(self, make_config): def test_alpine_returns_package_defaults(self, make_config):
cfg = make_config({"r": {"package": "alpine", "base_url": "https://x.com"}}) cfg = make_config({"r": {"type": "remote", "package": "alpine", "base_url": "https://x.com"}})
patterns = cfg.get_mutable_patterns("r") patterns = cfg.get_mutable_patterns("r")
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": {"package": "rpm", "base_url": "https://x.com"}}) cfg = make_config({"r": {"type": "remote", "package": "rpm", "base_url": "https://x.com"}})
patterns = cfg.get_mutable_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": {"package": "docker", "base_url": "https://x.com"}}) cfg = make_config({"r": {"type": "remote", "package": "docker", "base_url": "https://x.com"}})
patterns = cfg.get_mutable_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": {"package": "generic", "base_url": "https://x.com"}}) cfg = make_config({"r": {"type": "remote", "package": "generic", "base_url": "https://x.com"}})
assert cfg.get_mutable_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):
@@ -52,12 +52,12 @@ class TestGetMutablePatterns:
assert cfg.get_mutable_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": {"base_url": "https://x.com"}}) cfg = make_config({"r": {"type": "remote", "base_url": "https://x.com"}})
assert cfg.get_mutable_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": {"package": "deb", "base_url": "https://x.com"}}) cfg = make_config({"r": {"type": "remote", "package": "deb", "base_url": "https://x.com"}})
assert cfg.get_mutable_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):
@@ -134,7 +134,7 @@ class TestGetMutablePatterns:
assert r"custom-meta\.xml$" in patterns assert r"custom-meta\.xml$" in patterns
def test_npm_has_no_package_defaults(self, make_config): def test_npm_has_no_package_defaults(self, make_config):
cfg = make_config({"r": {"package": "npm", "base_url": "https://x.com"}}) cfg = make_config({"r": {"type": "remote", "package": "npm", "base_url": "https://x.com"}})
assert cfg.get_mutable_patterns("r") == [] assert cfg.get_mutable_patterns("r") == []
def test_npm_explicit_mutable_pattern_matches_metadata(self, make_config): def test_npm_explicit_mutable_pattern_matches_metadata(self, make_config):
@@ -155,14 +155,14 @@ class TestGetMutablePatterns:
assert any(re.search(p, "@babel/core") 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): def test_helm_returns_index_yaml_as_mutable(self, make_config):
cfg = make_config({"r": {"package": "helm", "base_url": "https://helm.example.com"}}) cfg = make_config({"r": {"type": "remote", "package": "helm", "base_url": "https://helm.example.com"}})
patterns = cfg.get_mutable_patterns("r") patterns = cfg.get_mutable_patterns("r")
assert r"index\.yaml$" in patterns assert r"index\.yaml$" in patterns
def test_helm_chart_tarballs_not_mutable_by_default(self, make_config): def test_helm_chart_tarballs_not_mutable_by_default(self, make_config):
import re import re
cfg = make_config({"r": {"package": "helm", "base_url": "https://helm.example.com"}}) cfg = make_config({"r": {"type": "remote", "package": "helm", "base_url": "https://helm.example.com"}})
patterns = cfg.get_mutable_patterns("r") patterns = cfg.get_mutable_patterns("r")
# Only index.yaml is mutable; .tgz chart tarballs are not # 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, "vault-0.29.1.tgz") for p in patterns)
@@ -210,7 +210,7 @@ class TestGetImmutablePatterns:
assert cfg.get_immutable_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": {"package": "generic", "base_url": "https://x.com"}}) cfg = make_config({"r": {"type": "remote", "package": "generic", "base_url": "https://x.com"}})
assert cfg.get_immutable_patterns("r") == [] assert cfg.get_immutable_patterns("r") == []
def test_multiple_patterns_returned(self, make_config): def test_multiple_patterns_returned(self, make_config):
@@ -281,7 +281,7 @@ class TestGetUserMutablePatterns:
def test_excludes_package_defaults(self, make_config): def test_excludes_package_defaults(self, make_config):
# Package defaults (APKINDEX etc.) must NOT appear here # Package defaults (APKINDEX etc.) must NOT appear here
cfg = make_config({"r": {"package": "alpine", "base_url": "https://x.com"}}) cfg = make_config({"r": {"type": "remote", "package": "alpine", "base_url": "https://x.com"}})
assert cfg.get_user_mutable_patterns("r") == [] assert cfg.get_user_mutable_patterns("r") == []
def test_returns_empty_for_missing_remote(self, make_config): def test_returns_empty_for_missing_remote(self, make_config):
@@ -289,7 +289,7 @@ class TestGetUserMutablePatterns:
assert cfg.get_user_mutable_patterns("nonexistent") == [] assert cfg.get_user_mutable_patterns("nonexistent") == []
def test_returns_empty_when_key_absent(self, make_config): def test_returns_empty_when_key_absent(self, make_config):
cfg = make_config({"r": {"package": "generic", "base_url": "https://x.com"}}) cfg = make_config({"r": {"type": "remote", "package": "generic", "base_url": "https://x.com"}})
assert cfg.get_user_mutable_patterns("r") == [] assert cfg.get_user_mutable_patterns("r") == []
@@ -317,7 +317,7 @@ class TestGetCacheConfig:
assert cfg.get_cache_config("nonexistent") == {} assert cfg.get_cache_config("nonexistent") == {}
def test_returns_empty_dict_when_no_cache_key(self, make_config): def test_returns_empty_dict_when_no_cache_key(self, make_config):
cfg = make_config({"r": {"package": "generic", "base_url": "https://x.com"}}) cfg = make_config({"r": {"type": "remote", "package": "generic", "base_url": "https://x.com"}})
assert cfg.get_cache_config("r") == {} assert cfg.get_cache_config("r") == {}
@@ -329,11 +329,11 @@ class TestGetCacheConfig:
class TestConfigReload: class TestConfigReload:
def test_reloads_when_file_mtime_advances(self, tmp_path): def test_reloads_when_file_mtime_advances(self, tmp_path):
cfg_file = tmp_path / "remotes.yaml" cfg_file = tmp_path / "remotes.yaml"
cfg_file.write_text(yaml.dump({"remotes": {"repo-a": {"package": "generic", "base_url": "https://x.com"}}})) cfg_file.write_text(yaml.dump({"remotes": {"repo-a": {"type": "remote", "package": "generic", "base_url": "https://x.com"}}}))
cfg = ConfigManager(str(cfg_file)) cfg = ConfigManager(str(cfg_file))
assert "repo-a" in cfg.config["remotes"] assert "repo-a" in cfg.config["remotes"]
cfg_file.write_text(yaml.dump({"remotes": {"repo-b": {"package": "generic", "base_url": "https://y.com"}}})) cfg_file.write_text(yaml.dump({"remotes": {"repo-b": {"type": "remote", "package": "generic", "base_url": "https://y.com"}}}))
future_mtime = cfg._last_modified + 1 future_mtime = cfg._last_modified + 1
os.utime(str(cfg_file), (future_mtime, future_mtime)) os.utime(str(cfg_file), (future_mtime, future_mtime))
@@ -344,7 +344,7 @@ class TestConfigReload:
def test_no_reload_when_file_unchanged(self, tmp_path): def test_no_reload_when_file_unchanged(self, tmp_path):
cfg_file = tmp_path / "remotes.yaml" cfg_file = tmp_path / "remotes.yaml"
cfg_file.write_text(yaml.dump({"remotes": {"repo-a": {"package": "generic", "base_url": "https://x.com"}}})) cfg_file.write_text(yaml.dump({"remotes": {"repo-a": {"type": "remote", "package": "generic", "base_url": "https://x.com"}}}))
cfg = ConfigManager(str(cfg_file)) cfg = ConfigManager(str(cfg_file))
# Call check_reload without touching the file — should not reload # Call check_reload without touching the file — should not reload
@@ -360,7 +360,7 @@ class TestConfigReload:
class TestGetQuarantineConfig: class TestGetQuarantineConfig:
def test_returns_false_zero_when_not_configured(self, make_config): def test_returns_false_zero_when_not_configured(self, make_config):
cfg = make_config({"r": {"package": "generic", "base_url": "https://x.com"}}) cfg = make_config({"r": {"type": "remote", "package": "generic", "base_url": "https://x.com"}})
enabled, days = cfg.get_quarantine_config("r") enabled, days = cfg.get_quarantine_config("r")
assert enabled is False assert enabled is False
assert days == 0 assert days == 0
@@ -426,7 +426,7 @@ class TestGetQuarantineConfig:
def _remote(base_url: str = "https://x.com") -> dict: def _remote(base_url: str = "https://x.com") -> dict:
return {"package": "generic", "base_url": base_url} return {"type": "remote", "package": "generic", "base_url": base_url}
class TestConfigDirMode: class TestConfigDirMode:
@@ -445,7 +445,7 @@ class TestConfigDirMode:
def test_empty_directory_returns_empty_remotes(self, tmp_path): def test_empty_directory_returns_empty_remotes(self, tmp_path):
cfg = ConfigManager(str(tmp_path)) cfg = ConfigManager(str(tmp_path))
assert cfg.config == {"remotes": {}, "virtuals": {}, "locals": {}} assert cfg.config == {"remotes": {}}
def test_ignores_non_yaml_files(self, tmp_path): def test_ignores_non_yaml_files(self, tmp_path):
(tmp_path / "notes.txt").write_text("not yaml") (tmp_path / "notes.txt").write_text("not yaml")
+26 -11
View File
@@ -523,53 +523,68 @@ class TestGenericArtifactRoute:
deps["database"].get_local_file_metadata.return_value = None deps["database"].get_local_file_metadata.return_value = None
deps["database"].available = True deps["database"].available = True
response = client.get("/api/v1/local/local-test/path/to/nonexistent.bin") response = client.get("/api/v1/remote/local-test/path/to/nonexistent.bin")
assert response.status_code == 404 assert response.status_code == 404
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Upload route PUT /api/v1/local/{local}/{path} # Upload route PUT /api/v1/remote/{remote}/{path}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestUploadRoute: class TestUploadRoute:
def test_unknown_local_returns_404(self, client, patched_deps): def test_unknown_remote_returns_404(self, client, patched_deps):
response = client.put( response = client.put(
"/api/v1/local/nonexistent/path/to/file.tar.gz", "/api/v1/remote/nonexistent/path/to/file.tar.gz",
files={"file": ("file.tar.gz", b"content", "application/octet-stream")}, files={"file": ("file.tar.gz", b"content", "application/octet-stream")},
) )
assert response.status_code == 404 assert response.status_code == 404
def test_non_local_remote_returns_400(self, client, patched_deps):
response = client.put(
"/api/v1/remote/generic-test/path/to/file.tar.gz",
files={"file": ("file.tar.gz", b"content", "application/octet-stream")},
)
assert response.status_code == 400
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# HEAD route HEAD /api/v1/local/{local}/{path} # HEAD route HEAD /api/v1/remote/{remote}/{path}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestHeadRoute: class TestHeadRoute:
def test_non_local_remote_returns_405(self, client, patched_deps):
response = client.head("/api/v1/remote/generic-test/path/to/file.tar.gz")
assert response.status_code == 405
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
deps["database"].available = True deps["database"].available = True
response = client.head("/api/v1/local/local-test/path/to/nonexistent.bin") response = client.head("/api/v1/remote/local-test/path/to/nonexistent.bin")
assert response.status_code == 404 assert response.status_code == 404
def test_unknown_local_returns_404(self, client, patched_deps): def test_unknown_remote_returns_404(self, client, patched_deps):
response = client.head("/api/v1/local/nonexistent/path/to/file.bin") response = client.head("/api/v1/remote/nonexistent/path/to/file.bin")
assert response.status_code == 404 assert response.status_code == 404
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# DELETE route DELETE /api/v1/local/{local}/{path} # DELETE route DELETE /api/v1/remote/{remote}/{path}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestDeleteRoute: class TestDeleteRoute:
def test_unknown_local_returns_404(self, client, patched_deps): def test_unknown_remote_returns_404(self, client, patched_deps):
response = client.delete("/api/v1/local/nonexistent/path/to/file.tar.gz") response = client.delete("/api/v1/remote/nonexistent/path/to/file.tar.gz")
assert response.status_code == 404 assert response.status_code == 404
def test_non_local_remote_returns_400(self, client, patched_deps):
response = client.delete("/api/v1/remote/generic-test/path/to/file.tar.gz")
assert response.status_code == 400
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Cache flush PUT /cache/flush # Cache flush PUT /cache/flush
+3 -3
View File
@@ -430,10 +430,10 @@ class TestVirtualRoute:
response = client.get("/api/v1/virtual/no-such-virtual/index.yaml") response = client.get("/api/v1/virtual/no-such-virtual/index.yaml")
assert response.status_code == 404 assert response.status_code == 404
def test_non_virtual_name_returns_404(self, client, patched_virtual_deps): def test_non_virtual_type_returns_400(self, client, patched_virtual_deps):
# helm-test is in remotes, not virtuals # helm-test is type "remote", not "virtual"
response = client.get("/api/v1/virtual/helm-test/index.yaml") response = client.get("/api/v1/virtual/helm-test/index.yaml")
assert response.status_code == 404 assert response.status_code == 400
def test_unsupported_package_returns_400(self, client, patched_virtual_deps): def test_unsupported_package_returns_400(self, client, patched_virtual_deps):
# unsupported-virtual-test has package "rpm" # unsupported-virtual-test has package "rpm"