Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 34160032fc | |||
| c7baae8d0d |
@@ -5,6 +5,7 @@ FastAPI caching proxy that downloads and stores files from remote sources in S3-
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Remote definitions via `remotes.yaml` — generic HTTP, Alpine APK, RPM, Docker, PyPI, npm, Helm
|
- Remote definitions via `remotes.yaml` — generic HTTP, Alpine APK, RPM, Docker, PyPI, npm, Helm
|
||||||
|
- Virtual repositories — merge multiple remotes of the same package type into a single unified index
|
||||||
- Immutable/mutable caching model with per-remote TTLs
|
- Immutable/mutable caching model with per-remote TTLs
|
||||||
- Conditional revalidation (`If-None-Match` / `If-Modified-Since`) on TTL expiry
|
- Conditional revalidation (`If-None-Match` / `If-Modified-Since`) on TTL expiry
|
||||||
- Stale-on-upstream-error: refreshes TTL when backend is unreachable rather than evicting
|
- Stale-on-upstream-error: refreshes TTL when backend is unreachable rather than evicting
|
||||||
@@ -37,6 +38,7 @@ src/artifactapi/
|
|||||||
├── docker_auth.py — backwards-compat shim → auth/docker.py
|
├── docker_auth.py — backwards-compat shim → auth/docker.py
|
||||||
├── artifact/ — route handler implementations
|
├── artifact/ — route handler implementations
|
||||||
│ ├── proxy.py — GET /api/v1/remote (remote proxy, cache, revalidation)
|
│ ├── proxy.py — GET /api/v1/remote (remote proxy, cache, revalidation)
|
||||||
|
│ ├── virtual.py — GET /api/v1/virtual (virtual repo index merging)
|
||||||
│ ├── local.py — PUT/HEAD/DELETE /api/v1/remote (local repos)
|
│ ├── local.py — PUT/HEAD/DELETE /api/v1/remote (local repos)
|
||||||
│ ├── docker.py — /v2/ Docker Registry v2 proxy
|
│ ├── docker.py — /v2/ Docker Registry v2 proxy
|
||||||
│ ├── discovery.py — /api/v1/artifacts discovery + bulk cache
|
│ ├── discovery.py — /api/v1/artifacts discovery + bulk cache
|
||||||
@@ -71,6 +73,7 @@ src/artifactapi/
|
|||||||
| `PUT` | `/api/v1/remote/{remote}/{path}` | Upload to local remote |
|
| `PUT` | `/api/v1/remote/{remote}/{path}` | Upload to local remote |
|
||||||
| `HEAD` | `/api/v1/remote/{remote}/{path}` | Check existence (local remotes) |
|
| `HEAD` | `/api/v1/remote/{remote}/{path}` | Check existence (local remotes) |
|
||||||
| `DELETE` | `/api/v1/remote/{remote}/{path}` | Delete from local remote |
|
| `DELETE` | `/api/v1/remote/{remote}/{path}` | Delete from local remote |
|
||||||
|
| `GET` | `/api/v1/virtual/{virtual}/{path}` | Fetch from virtual (merged) 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 |
|
||||||
@@ -119,11 +122,12 @@ remotes: {} # optional base remotes
|
|||||||
|
|
||||||
### remotes.yaml Structure
|
### remotes.yaml Structure
|
||||||
|
|
||||||
|
The top-level key declares the repository type — no `type:` field needed:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
remotes:
|
remote:
|
||||||
remote-name:
|
remote-name:
|
||||||
base_url: "https://example.com"
|
base_url: "https://example.com"
|
||||||
type: "remote" # "remote" or "local"
|
|
||||||
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
|
||||||
@@ -134,6 +138,17 @@ remotes:
|
|||||||
cache:
|
cache:
|
||||||
immutable_ttl: 0 # 0 = indefinitely
|
immutable_ttl: 0 # 0 = indefinitely
|
||||||
mutable_ttl: 3600
|
mutable_ttl: 3600
|
||||||
|
|
||||||
|
virtual:
|
||||||
|
virtual-name:
|
||||||
|
package: "helm"
|
||||||
|
members:
|
||||||
|
- remote-name-1
|
||||||
|
- remote-name-2
|
||||||
|
|
||||||
|
local:
|
||||||
|
local-name:
|
||||||
|
package: "generic"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Remote Types
|
## Remote Types
|
||||||
@@ -143,10 +158,9 @@ remotes:
|
|||||||
Arbitrary HTTP file servers — GitHub releases, HashiCorp, custom servers.
|
Arbitrary HTTP file servers — GitHub releases, HashiCorp, custom servers.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
remotes:
|
remote:
|
||||||
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.*"
|
||||||
@@ -155,7 +169,6 @@ 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
|
||||||
@@ -172,10 +185,9 @@ Access: `GET /api/v1/remote/github/owner/repo/releases/download/v1.0/binary.tar.
|
|||||||
### alpine
|
### alpine
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
remotes:
|
remote:
|
||||||
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$"
|
||||||
@@ -189,10 +201,9 @@ remotes:
|
|||||||
### rpm
|
### rpm
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
remotes:
|
remote:
|
||||||
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$"
|
||||||
@@ -207,10 +218,9 @@ remotes:
|
|||||||
### docker
|
### docker
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
remotes:
|
remote:
|
||||||
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:
|
||||||
@@ -219,7 +229,6 @@ 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
|
||||||
@@ -249,10 +258,9 @@ mirrors:
|
|||||||
### pypi
|
### pypi
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
remotes:
|
remote:
|
||||||
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:
|
||||||
@@ -281,10 +289,9 @@ default = true
|
|||||||
### npm
|
### npm
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
remotes:
|
remote:
|
||||||
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:
|
||||||
@@ -308,10 +315,9 @@ registry=https://artifacts.example.com/api/v1/remote/npm/
|
|||||||
### helm
|
### helm
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
remotes:
|
remote:
|
||||||
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:
|
||||||
@@ -330,12 +336,72 @@ helm repo add hashicorp https://artifacts.example.com/api/v1/remote/hashicorp-he
|
|||||||
helm repo update
|
helm repo update
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### virtual
|
||||||
|
|
||||||
|
A virtual repository presents a single unified index built from multiple member remotes of the same package type. Clients configure one endpoint and get access to all member remotes transparently.
|
||||||
|
|
||||||
|
All members must share the same `package` type as the virtual repo. Currently supported package types: `helm`.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
remote:
|
||||||
|
helm-hashicorp:
|
||||||
|
base_url: "https://helm.releases.hashicorp.com"
|
||||||
|
package: "helm"
|
||||||
|
immutable_patterns:
|
||||||
|
- "\\.tgz$"
|
||||||
|
cache:
|
||||||
|
immutable_ttl: 0
|
||||||
|
mutable_ttl: 3600
|
||||||
|
|
||||||
|
helm-bitnami:
|
||||||
|
base_url: "https://charts.bitnami.com/bitnami"
|
||||||
|
package: "helm"
|
||||||
|
immutable_patterns:
|
||||||
|
- "\\.tgz$"
|
||||||
|
cache:
|
||||||
|
immutable_ttl: 0
|
||||||
|
mutable_ttl: 3600
|
||||||
|
|
||||||
|
virtual:
|
||||||
|
helm-all:
|
||||||
|
package: "helm"
|
||||||
|
members:
|
||||||
|
- helm-hashicorp # listed first = highest priority
|
||||||
|
- helm-bitnami
|
||||||
|
```
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
|
||||||
|
1. A request for the package index triggers a parallel fetch of each member's index from S3 cache, falling back to upstream if not yet cached.
|
||||||
|
2. Member indexes are merged into a single index with URL rewriting so artifact download URLs continue to resolve through the individual member remote.
|
||||||
|
3. The merged index is cached in Redis with a TTL equal to the minimum `mutable_ttl` across all members.
|
||||||
|
|
||||||
|
**Priority / conflict resolution:**
|
||||||
|
|
||||||
|
When the same artifact name and version appears in more than one member, the member listed **first** in `members` wins. Subsequent members contribute only artifacts not already present.
|
||||||
|
|
||||||
|
**Partial failures:**
|
||||||
|
|
||||||
|
If a member is unreachable and has no cached index, it is skipped and a warning is logged. The merged index is still served from available members. If *no* members can be reached, the request returns `502`.
|
||||||
|
|
||||||
|
**Caching:**
|
||||||
|
|
||||||
|
The merged index is cached using `min(mutable_ttl)` across all members. Each member's raw index is cached in S3 under its own remote key by the normal proxy rules; the virtual handler reuses those copies when available.
|
||||||
|
|
||||||
|
**Helm example:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
helm repo add all https://artifacts.example.com/api/v1/virtual/helm-all
|
||||||
|
helm repo update
|
||||||
|
```
|
||||||
|
|
||||||
|
Chart tarball URLs in the merged `index.yaml` are rewritten to point at the individual member remote (e.g. `…/api/v1/remote/helm-hashicorp/vault-0.27.0.tgz`), so downloads bypass the virtual endpoint entirely.
|
||||||
|
|
||||||
### local
|
### local
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
remotes:
|
local:
|
||||||
local-generic:
|
local-generic:
|
||||||
type: "local"
|
|
||||||
package: "generic"
|
package: "generic"
|
||||||
description: "Local file repository"
|
description: "Local file repository"
|
||||||
cache:
|
cache:
|
||||||
@@ -345,6 +411,98 @@ remotes:
|
|||||||
|
|
||||||
No `base_url`. Files are uploaded via `PUT` and served via `GET`.
|
No `base_url`. Files are uploaded via `PUT` and served via `GET`.
|
||||||
|
|
||||||
|
## Migration
|
||||||
|
|
||||||
|
### Splitting a single remotes file into per-type files
|
||||||
|
|
||||||
|
The old format used a single `remotes:` map with an explicit `type:` field on each entry. The new format uses top-level type keys (`remote:`, `virtual:`, `local:`) and supports splitting across multiple files via `config_dir`.
|
||||||
|
|
||||||
|
**Before** (`remotes.yaml`):
|
||||||
|
```yaml
|
||||||
|
remotes:
|
||||||
|
dockerhub:
|
||||||
|
base_url: "https://registry-1.docker.io"
|
||||||
|
type: "remote"
|
||||||
|
package: "docker"
|
||||||
|
cache:
|
||||||
|
immutable_ttl: 0
|
||||||
|
mutable_ttl: 300
|
||||||
|
|
||||||
|
hashicorp-helm:
|
||||||
|
base_url: "https://helm.releases.hashicorp.com"
|
||||||
|
type: "remote"
|
||||||
|
package: "helm"
|
||||||
|
immutable_patterns:
|
||||||
|
- "\\.tgz$"
|
||||||
|
cache:
|
||||||
|
immutable_ttl: 0
|
||||||
|
mutable_ttl: 3600
|
||||||
|
|
||||||
|
helm-all:
|
||||||
|
type: "virtual"
|
||||||
|
package: "helm"
|
||||||
|
members:
|
||||||
|
- hashicorp-helm
|
||||||
|
|
||||||
|
local-files:
|
||||||
|
type: "local"
|
||||||
|
package: "generic"
|
||||||
|
```
|
||||||
|
|
||||||
|
**After** — one file per type + package type, with a main config pointing at the directory:
|
||||||
|
|
||||||
|
`config.yaml`:
|
||||||
|
```yaml
|
||||||
|
config_dir: conf.d
|
||||||
|
```
|
||||||
|
|
||||||
|
`conf.d/remote-docker.yaml`:
|
||||||
|
```yaml
|
||||||
|
remote:
|
||||||
|
dockerhub:
|
||||||
|
base_url: "https://registry-1.docker.io"
|
||||||
|
package: "docker"
|
||||||
|
cache:
|
||||||
|
immutable_ttl: 0
|
||||||
|
mutable_ttl: 300
|
||||||
|
```
|
||||||
|
|
||||||
|
`conf.d/remote-helm.yaml`:
|
||||||
|
```yaml
|
||||||
|
remote:
|
||||||
|
hashicorp-helm:
|
||||||
|
base_url: "https://helm.releases.hashicorp.com"
|
||||||
|
package: "helm"
|
||||||
|
immutable_patterns:
|
||||||
|
- "\\.tgz$"
|
||||||
|
cache:
|
||||||
|
immutable_ttl: 0
|
||||||
|
mutable_ttl: 3600
|
||||||
|
```
|
||||||
|
|
||||||
|
`conf.d/virtual-helm.yaml`:
|
||||||
|
```yaml
|
||||||
|
virtual:
|
||||||
|
helm-all:
|
||||||
|
package: "helm"
|
||||||
|
members:
|
||||||
|
- hashicorp-helm
|
||||||
|
```
|
||||||
|
|
||||||
|
`conf.d/local-generic.yaml`:
|
||||||
|
```yaml
|
||||||
|
local:
|
||||||
|
local-files:
|
||||||
|
package: "generic"
|
||||||
|
```
|
||||||
|
|
||||||
|
Set `CONFIG_PATH` to the main file:
|
||||||
|
```
|
||||||
|
CONFIG_PATH=/etc/artifactapi/config.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
Files in `conf.d/` are merged alphabetically; later files win on conflicts within the same remote name.
|
||||||
|
|
||||||
## Caching Model
|
## Caching Model
|
||||||
|
|
||||||
### Immutable patterns
|
### Immutable patterns
|
||||||
@@ -382,10 +540,9 @@ When a mutable file expires and the upstream is unreachable (connection refused,
|
|||||||
Set `quarantine_new: true` and `quarantine_days: N` on a remote to block immutable artifacts published within the last N days. Requests return `404` until the quarantine period expires, giving time to detect malicious packages before they are consumed.
|
Set `quarantine_new: true` and `quarantine_days: N` on a remote to block immutable artifacts published within the last N days. Requests return `404` until the quarantine period expires, giving time to detect malicious packages before they are consumed.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
remotes:
|
remote:
|
||||||
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
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
remotes:
|
remote:
|
||||||
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,7 +1,6 @@
|
|||||||
remotes:
|
remote:
|
||||||
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,7 +1,6 @@
|
|||||||
remotes:
|
remote:
|
||||||
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
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# Example remotes configuration — copy and adapt for your environment.
|
# Example remotes configuration — copy and adapt for your environment.
|
||||||
#
|
#
|
||||||
|
# Top-level keys determine the repository type:
|
||||||
|
# remote: — proxy to an upstream URL, cache responses in S3
|
||||||
|
# virtual: — merge indexes from multiple member remotes into one
|
||||||
|
# local: — store files uploaded via PUT, serve via GET
|
||||||
|
#
|
||||||
# immutable_patterns: artifacts cached forever (e.g. release binaries, versioned tags).
|
# immutable_patterns: artifacts cached forever (e.g. release binaries, versioned tags).
|
||||||
# mutable_patterns: artifacts that expire after cache.mutable_ttl seconds and are
|
# mutable_patterns: artifacts that expire after cache.mutable_ttl seconds and are
|
||||||
# re-fetched from upstream on next request (e.g. index files,
|
# re-fetched from upstream on next request (e.g. index files,
|
||||||
@@ -32,10 +37,10 @@
|
|||||||
#database:
|
#database:
|
||||||
# url: "postgresql://artifacts:artifacts123@localhost:5432/artifacts"
|
# url: "postgresql://artifacts:artifacts123@localhost:5432/artifacts"
|
||||||
#
|
#
|
||||||
remotes:
|
|
||||||
|
remote:
|
||||||
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:
|
||||||
@@ -62,12 +67,11 @@ remotes:
|
|||||||
- "stalwartlabs/stalwart/.*/stalwart-foundationdb-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$"
|
- "stalwartlabs/stalwart/.*/stalwart-x86_64-unknown-linux-gnu\\.tar\\.gz$"
|
||||||
cache:
|
cache:
|
||||||
immutable_ttl: 0 # Files cached indefinitely
|
immutable_ttl: 0
|
||||||
mutable_ttl: 0
|
mutable_ttl: 0
|
||||||
|
|
||||||
github-archive:
|
github-archive:
|
||||||
base_url: "https://github.com"
|
base_url: "https://github.com"
|
||||||
type: "remote"
|
|
||||||
package: "generic"
|
package: "generic"
|
||||||
description: "GitHub repository archive tarballs"
|
description: "GitHub repository archive tarballs"
|
||||||
immutable_patterns:
|
immutable_patterns:
|
||||||
@@ -87,18 +91,16 @@ remotes:
|
|||||||
|
|
||||||
gitea-dl:
|
gitea-dl:
|
||||||
base_url: "https://dl.gitea.com"
|
base_url: "https://dl.gitea.com"
|
||||||
type: "remote"
|
|
||||||
package: "generic"
|
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$"
|
||||||
cache:
|
cache:
|
||||||
immutable_ttl: 0 # Files cached indefinitely
|
immutable_ttl: 0
|
||||||
mutable_ttl: 0
|
mutable_ttl: 0
|
||||||
|
|
||||||
hashicorp-releases:
|
hashicorp-releases:
|
||||||
base_url: "https://releases.hashicorp.com"
|
base_url: "https://releases.hashicorp.com"
|
||||||
type: "remote"
|
|
||||||
package: "generic"
|
package: "generic"
|
||||||
description: "HashiCorp product releases"
|
description: "HashiCorp product releases"
|
||||||
immutable_patterns:
|
immutable_patterns:
|
||||||
@@ -114,12 +116,11 @@ remotes:
|
|||||||
- "nomad/.*/nomad_.*_linux_amd64\\.zip$"
|
- "nomad/.*/nomad_.*_linux_amd64\\.zip$"
|
||||||
- "packer/.*/packer_.*_linux_amd64\\.zip$"
|
- "packer/.*/packer_.*_linux_amd64\\.zip$"
|
||||||
cache:
|
cache:
|
||||||
immutable_ttl: 0 # Files cached indefinitely
|
immutable_ttl: 0
|
||||||
mutable_ttl: 0
|
mutable_ttl: 0
|
||||||
|
|
||||||
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:
|
||||||
@@ -128,29 +129,24 @@ remotes:
|
|||||||
# and is always re-fetched on expiry — conditional checks are skipped for
|
# and is always re-fetched on expiry — conditional checks are skipped for
|
||||||
# built-in mutable patterns regardless of this flag.
|
# built-in mutable patterns regardless of this flag.
|
||||||
cache:
|
cache:
|
||||||
immutable_ttl: 0 # Files cached indefinitely
|
immutable_ttl: 0
|
||||||
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"
|
base_url: "https://gsl-syd.mm.fcix.net/almalinux"
|
||||||
type: "remote"
|
|
||||||
package: "rpm"
|
package: "rpm"
|
||||||
description: "AlmaLinux RPM package repository"
|
description: "AlmaLinux RPM package repository"
|
||||||
immutable_patterns:
|
immutable_patterns:
|
||||||
- ".*/x86_64/.*\\.rpm$"
|
- ".*/x86_64/.*\\.rpm$"
|
||||||
- ".*/noarch/.*\\.rpm$"
|
- ".*/noarch/.*\\.rpm$"
|
||||||
- ".*/repodata/.*$"
|
- ".*/repodata/.*$"
|
||||||
- ".*\\.rpm$" # Allow all RPM files
|
- ".*\\.rpm$"
|
||||||
# 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:
|
cache:
|
||||||
immutable_ttl: 0 # Files cached indefinitely
|
immutable_ttl: 0
|
||||||
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"
|
base_url: "http://mirror.aarnet.edu.au/pub/epel"
|
||||||
type: "remote"
|
|
||||||
package: "rpm"
|
package: "rpm"
|
||||||
description: "EPEL (Extra Packages for Enterprise Linux)"
|
description: "EPEL (Extra Packages for Enterprise Linux)"
|
||||||
immutable_patterns:
|
immutable_patterns:
|
||||||
@@ -160,12 +156,11 @@ remotes:
|
|||||||
- ".*/noarch/.*\\.rpm$"
|
- ".*/noarch/.*\\.rpm$"
|
||||||
- ".*/repodata/.*$"
|
- ".*/repodata/.*$"
|
||||||
cache:
|
cache:
|
||||||
immutable_ttl: 0 # Files cached indefinitely
|
immutable_ttl: 0
|
||||||
mutable_ttl: 7200 # Metadata files cached for 2 hours
|
mutable_ttl: 7200
|
||||||
|
|
||||||
fedora:
|
fedora:
|
||||||
base_url: "https://gsl-syd.mm.fcix.net/fedora/linux"
|
base_url: "https://gsl-syd.mm.fcix.net/fedora/linux"
|
||||||
type: "remote"
|
|
||||||
package: "rpm"
|
package: "rpm"
|
||||||
description: "Fedora Linux RPM package repository"
|
description: "Fedora Linux RPM package repository"
|
||||||
immutable_patterns:
|
immutable_patterns:
|
||||||
@@ -175,26 +170,21 @@ remotes:
|
|||||||
- ".*/noarch/.*\\.rpm$"
|
- ".*/noarch/.*\\.rpm$"
|
||||||
- "updates/.*/Everything/x86_64/repodata/.*$"
|
- "updates/.*/Everything/x86_64/repodata/.*$"
|
||||||
cache:
|
cache:
|
||||||
immutable_ttl: 0 # Files cached indefinitely
|
immutable_ttl: 0
|
||||||
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"
|
base_url: "https://ghcr.io"
|
||||||
type: "remote"
|
|
||||||
package: "docker"
|
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
|
||||||
# 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:
|
cache:
|
||||||
immutable_ttl: 0
|
immutable_ttl: 0
|
||||||
mutable_ttl: 300
|
mutable_ttl: 300
|
||||||
|
|
||||||
dockerhub:
|
dockerhub:
|
||||||
base_url: "https://registry-1.docker.io"
|
base_url: "https://registry-1.docker.io"
|
||||||
type: "remote"
|
|
||||||
package: "docker"
|
package: "docker"
|
||||||
description: "Docker Hub registry"
|
description: "Docker Hub registry"
|
||||||
cache:
|
cache:
|
||||||
@@ -203,15 +193,9 @@ 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 — 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
|
|
||||||
# files.pythonhosted.org (base_url). URLs in the simple index are rewritten to this remote.
|
|
||||||
check_mutable_updates: true
|
check_mutable_updates: true
|
||||||
# Block packages published within the last 3 days (supply-chain attack mitigation).
|
|
||||||
# Immutable artifacts (wheel/sdist) newer than quarantine_days return 404 until
|
|
||||||
# the window passes. Disable by setting quarantine_new: false or removing both keys.
|
|
||||||
quarantine_new: true
|
quarantine_new: true
|
||||||
quarantine_days: 3
|
quarantine_days: 3
|
||||||
immutable_patterns:
|
immutable_patterns:
|
||||||
@@ -226,7 +210,6 @@ remotes:
|
|||||||
|
|
||||||
pypi-gitea:
|
pypi-gitea:
|
||||||
base_url: "https://gitea.example.com/api/packages/myorg/pypi"
|
base_url: "https://gitea.example.com/api/packages/myorg/pypi"
|
||||||
type: "remote"
|
|
||||||
package: "pypi"
|
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"
|
||||||
@@ -244,7 +227,6 @@ remotes:
|
|||||||
|
|
||||||
npm:
|
npm:
|
||||||
base_url: "https://registry.npmjs.org"
|
base_url: "https://registry.npmjs.org"
|
||||||
type: "remote"
|
|
||||||
package: "npm"
|
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
|
||||||
@@ -258,20 +240,242 @@ 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"
|
||||||
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:
|
||||||
- "\\.tgz$"
|
- "\\.tgz$"
|
||||||
cache:
|
cache:
|
||||||
immutable_ttl: 0 # Chart tarballs are versioned — cache forever
|
immutable_ttl: 0
|
||||||
mutable_ttl: 3600 # index.yaml refreshed after 1 hour
|
mutable_ttl: 3600
|
||||||
|
|
||||||
|
metallb:
|
||||||
|
base_url: "https://metallb.github.io/metallb"
|
||||||
|
package: "helm"
|
||||||
|
description: "MetalLB load balancer Helm charts"
|
||||||
|
check_mutable_updates: true
|
||||||
|
immutable_patterns:
|
||||||
|
- "\\.tgz$"
|
||||||
|
cache:
|
||||||
|
immutable_ttl: 0
|
||||||
|
mutable_ttl: 3600
|
||||||
|
|
||||||
|
jetstack:
|
||||||
|
base_url: "https://charts.jetstack.io"
|
||||||
|
package: "helm"
|
||||||
|
description: "Jetstack Helm charts (cert-manager)"
|
||||||
|
check_mutable_updates: true
|
||||||
|
immutable_patterns:
|
||||||
|
- "\\.tgz$"
|
||||||
|
cache:
|
||||||
|
immutable_ttl: 0
|
||||||
|
mutable_ttl: 3600
|
||||||
|
|
||||||
|
rancher-stable:
|
||||||
|
base_url: "https://releases.rancher.com/server-charts/stable"
|
||||||
|
package: "helm"
|
||||||
|
description: "Rancher stable Helm charts"
|
||||||
|
check_mutable_updates: true
|
||||||
|
immutable_patterns:
|
||||||
|
- "\\.tgz$"
|
||||||
|
cache:
|
||||||
|
immutable_ttl: 0
|
||||||
|
mutable_ttl: 3600
|
||||||
|
|
||||||
|
purelb:
|
||||||
|
base_url: "https://gitlab.com/api/v4/projects/20400619/packages/helm/stable"
|
||||||
|
package: "helm"
|
||||||
|
description: "PureLB load balancer Helm charts"
|
||||||
|
check_mutable_updates: true
|
||||||
|
immutable_patterns:
|
||||||
|
- "\\.tgz$"
|
||||||
|
cache:
|
||||||
|
immutable_ttl: 0
|
||||||
|
mutable_ttl: 3600
|
||||||
|
|
||||||
|
istio:
|
||||||
|
base_url: "https://istio-release.storage.googleapis.com/charts"
|
||||||
|
package: "helm"
|
||||||
|
description: "Istio service mesh Helm charts"
|
||||||
|
check_mutable_updates: true
|
||||||
|
immutable_patterns:
|
||||||
|
- "\\.tgz$"
|
||||||
|
cache:
|
||||||
|
immutable_ttl: 0
|
||||||
|
mutable_ttl: 3600
|
||||||
|
|
||||||
|
cnpg:
|
||||||
|
base_url: "https://cloudnative-pg.github.io/charts"
|
||||||
|
package: "helm"
|
||||||
|
description: "CloudNativePG operator Helm charts"
|
||||||
|
check_mutable_updates: true
|
||||||
|
immutable_patterns:
|
||||||
|
- "\\.tgz$"
|
||||||
|
cache:
|
||||||
|
immutable_ttl: 0
|
||||||
|
mutable_ttl: 3600
|
||||||
|
|
||||||
|
ceph-csi:
|
||||||
|
base_url: "https://ceph.github.io/csi-charts"
|
||||||
|
package: "helm"
|
||||||
|
description: "Ceph CSI driver Helm charts"
|
||||||
|
check_mutable_updates: true
|
||||||
|
immutable_patterns:
|
||||||
|
- "\\.tgz$"
|
||||||
|
cache:
|
||||||
|
immutable_ttl: 0
|
||||||
|
mutable_ttl: 3600
|
||||||
|
|
||||||
|
external-dns:
|
||||||
|
base_url: "https://kubernetes-sigs.github.io/external-dns/"
|
||||||
|
package: "helm"
|
||||||
|
description: "ExternalDNS Helm charts"
|
||||||
|
check_mutable_updates: true
|
||||||
|
immutable_patterns:
|
||||||
|
- "\\.tgz$"
|
||||||
|
cache:
|
||||||
|
immutable_ttl: 0
|
||||||
|
mutable_ttl: 3600
|
||||||
|
|
||||||
|
intel-helm:
|
||||||
|
base_url: "https://intel.github.io/helm-charts/"
|
||||||
|
package: "helm"
|
||||||
|
description: "Intel Helm charts"
|
||||||
|
check_mutable_updates: true
|
||||||
|
immutable_patterns:
|
||||||
|
- "\\.tgz$"
|
||||||
|
cache:
|
||||||
|
immutable_ttl: 0
|
||||||
|
mutable_ttl: 3600
|
||||||
|
|
||||||
|
elastic:
|
||||||
|
base_url: "https://helm.elastic.co"
|
||||||
|
package: "helm"
|
||||||
|
description: "Elastic stack Helm charts"
|
||||||
|
check_mutable_updates: true
|
||||||
|
immutable_patterns:
|
||||||
|
- "\\.tgz$"
|
||||||
|
cache:
|
||||||
|
immutable_ttl: 0
|
||||||
|
mutable_ttl: 3600
|
||||||
|
|
||||||
|
k8up-io:
|
||||||
|
base_url: "https://k8up-io.github.io/k8up"
|
||||||
|
package: "helm"
|
||||||
|
description: "K8up backup operator Helm charts"
|
||||||
|
check_mutable_updates: true
|
||||||
|
immutable_patterns:
|
||||||
|
- "\\.tgz$"
|
||||||
|
cache:
|
||||||
|
immutable_ttl: 0
|
||||||
|
mutable_ttl: 3600
|
||||||
|
|
||||||
|
victoriametrics:
|
||||||
|
base_url: "https://victoriametrics.github.io/helm-charts/"
|
||||||
|
package: "helm"
|
||||||
|
description: "VictoriaMetrics observability Helm charts"
|
||||||
|
check_mutable_updates: true
|
||||||
|
immutable_patterns:
|
||||||
|
- "\\.tgz$"
|
||||||
|
cache:
|
||||||
|
immutable_ttl: 0
|
||||||
|
mutable_ttl: 3600
|
||||||
|
|
||||||
|
grafana:
|
||||||
|
base_url: "https://grafana.github.io/helm-charts"
|
||||||
|
package: "helm"
|
||||||
|
description: "Grafana observability Helm charts"
|
||||||
|
check_mutable_updates: true
|
||||||
|
immutable_patterns:
|
||||||
|
- "\\.tgz$"
|
||||||
|
cache:
|
||||||
|
immutable_ttl: 0
|
||||||
|
mutable_ttl: 3600
|
||||||
|
|
||||||
|
helm-openldap:
|
||||||
|
base_url: "https://jp-gouin.github.io/helm-openldap/"
|
||||||
|
package: "helm"
|
||||||
|
description: "OpenLDAP Helm charts"
|
||||||
|
check_mutable_updates: true
|
||||||
|
immutable_patterns:
|
||||||
|
- "\\.tgz$"
|
||||||
|
cache:
|
||||||
|
immutable_ttl: 0
|
||||||
|
mutable_ttl: 3600
|
||||||
|
|
||||||
|
woodpecker:
|
||||||
|
base_url: "https://woodpecker-ci.org/"
|
||||||
|
package: "helm"
|
||||||
|
description: "Woodpecker CI Helm charts"
|
||||||
|
check_mutable_updates: true
|
||||||
|
immutable_patterns:
|
||||||
|
- "\\.tgz$"
|
||||||
|
cache:
|
||||||
|
immutable_ttl: 0
|
||||||
|
mutable_ttl: 3600
|
||||||
|
|
||||||
|
stakater:
|
||||||
|
base_url: "https://stakater.github.io/stakater-charts"
|
||||||
|
package: "helm"
|
||||||
|
description: "Stakater Helm charts"
|
||||||
|
check_mutable_updates: true
|
||||||
|
immutable_patterns:
|
||||||
|
- "\\.tgz$"
|
||||||
|
cache:
|
||||||
|
immutable_ttl: 0
|
||||||
|
mutable_ttl: 3600
|
||||||
|
|
||||||
|
jfrog:
|
||||||
|
base_url: "https://charts.jfrog.io/"
|
||||||
|
package: "helm"
|
||||||
|
description: "JFrog Helm charts"
|
||||||
|
check_mutable_updates: true
|
||||||
|
immutable_patterns:
|
||||||
|
- "\\.tgz$"
|
||||||
|
cache:
|
||||||
|
immutable_ttl: 0
|
||||||
|
mutable_ttl: 3600
|
||||||
|
|
||||||
|
openvox:
|
||||||
|
base_url: "https://openvoxproject.github.io/openvox-helm-chart"
|
||||||
|
package: "helm"
|
||||||
|
description: "OpenVox Helm charts"
|
||||||
|
check_mutable_updates: true
|
||||||
|
immutable_patterns:
|
||||||
|
- "\\.tgz$"
|
||||||
|
cache:
|
||||||
|
immutable_ttl: 0
|
||||||
|
mutable_ttl: 3600
|
||||||
|
|
||||||
|
virtual:
|
||||||
|
helm-all:
|
||||||
|
package: "helm"
|
||||||
|
description: "Virtual repository merging all helm remotes — member order is priority order for duplicate chart+version"
|
||||||
|
members:
|
||||||
|
- hashicorp-helm
|
||||||
|
- metallb
|
||||||
|
- jetstack
|
||||||
|
- rancher-stable
|
||||||
|
- purelb
|
||||||
|
- istio
|
||||||
|
- cnpg
|
||||||
|
- ceph-csi
|
||||||
|
- external-dns
|
||||||
|
- intel-helm
|
||||||
|
- elastic
|
||||||
|
- k8up-io
|
||||||
|
- victoriametrics
|
||||||
|
- grafana
|
||||||
|
- helm-openldap
|
||||||
|
- woodpecker
|
||||||
|
- stakater
|
||||||
|
- jfrog
|
||||||
|
- openvox
|
||||||
|
|
||||||
|
local:
|
||||||
local-generic:
|
local-generic:
|
||||||
type: "local"
|
|
||||||
package: "generic"
|
package: "generic"
|
||||||
description: "Local generic file repository"
|
description: "Local generic file repository"
|
||||||
cache:
|
cache:
|
||||||
immutable_ttl: 0 # Files cached indefinitely
|
immutable_ttl: 0
|
||||||
mutable_ttl: 0
|
mutable_ttl: 0
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from datetime import UTC, date, datetime
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import yaml
|
||||||
|
from fastapi import HTTPException, Request, Response
|
||||||
|
|
||||||
|
from ..remote import helm as _helm
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class _HelmDumper(yaml.Dumper):
|
||||||
|
"""YAML dumper that serializes datetime/date objects back to ISO 8601 strings.
|
||||||
|
|
||||||
|
yaml.safe_load converts timestamp-shaped YAML scalars (e.g. chart `created`
|
||||||
|
fields) to Python datetime objects. Without a custom representer, yaml.dump
|
||||||
|
would render them as "2022-12-16 11:08:49+00:00" (space, not T), which
|
||||||
|
Go's YAML parser cannot unmarshal into time.Time.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _repr_datetime(dumper: yaml.Dumper, data: datetime) -> yaml.ScalarNode:
|
||||||
|
s = data.strftime("%Y-%m-%dT%H:%M:%S.%f") + ("Z" if data.tzinfo else "")
|
||||||
|
return dumper.represent_scalar("tag:yaml.org,2002:str", s)
|
||||||
|
|
||||||
|
|
||||||
|
def _repr_date(dumper: yaml.Dumper, data: date) -> yaml.ScalarNode:
|
||||||
|
return dumper.represent_scalar("tag:yaml.org,2002:str", data.isoformat())
|
||||||
|
|
||||||
|
|
||||||
|
_HelmDumper.add_representer(datetime, _repr_datetime)
|
||||||
|
_HelmDumper.add_representer(date, _repr_date)
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_member_index(
|
||||||
|
member_name: str,
|
||||||
|
member_cfg: dict,
|
||||||
|
path: str,
|
||||||
|
storage,
|
||||||
|
cache,
|
||||||
|
) -> tuple[str, dict, int, bytes | None]:
|
||||||
|
"""Fetch or retrieve cached index.yaml for one member remote.
|
||||||
|
|
||||||
|
Returns (member_name, member_cfg, ttl, raw_bytes).
|
||||||
|
raw_bytes is None if the member is unreachable and not in S3.
|
||||||
|
"""
|
||||||
|
member_ttl = member_cfg.get("cache", {}).get("mutable_ttl", 3600)
|
||||||
|
s3_key = storage.get_object_key(member_name, path)
|
||||||
|
raw_data: bytes | None = None
|
||||||
|
|
||||||
|
if storage.exists(s3_key) and cache.is_index_valid(member_name, path):
|
||||||
|
try:
|
||||||
|
raw_data = storage.download_object(s3_key)
|
||||||
|
logger.info(f"Virtual: cache hit for member '{member_name}'")
|
||||||
|
except Exception:
|
||||||
|
raw_data = None
|
||||||
|
|
||||||
|
if raw_data is None:
|
||||||
|
base_url = member_cfg.get("base_url", "").rstrip("/")
|
||||||
|
upstream_url = f"{base_url}/index.yaml"
|
||||||
|
headers = {}
|
||||||
|
username = member_cfg.get("username")
|
||||||
|
password = member_cfg.get("password")
|
||||||
|
if username and password:
|
||||||
|
token = base64.b64encode(f"{username}:{password}".encode()).decode()
|
||||||
|
headers["Authorization"] = f"Basic {token}"
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(follow_redirects=True) as client:
|
||||||
|
response = await client.get(upstream_url, headers=headers, timeout=30.0)
|
||||||
|
response.raise_for_status()
|
||||||
|
raw_data = response.content
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Virtual: failed to fetch index.yaml from member '{member_name}': {e}")
|
||||||
|
return member_name, member_cfg, member_ttl, None
|
||||||
|
try:
|
||||||
|
storage.upload(s3_key, raw_data)
|
||||||
|
cache.mark_index_cached(member_name, path, member_ttl)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Virtual: failed to cache index.yaml for member '{member_name}': {e}")
|
||||||
|
|
||||||
|
return member_name, member_cfg, member_ttl, raw_data
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_helm_indexes(raw_indexes: list[bytes], member_names: list[str], member_configs: list[dict], proxy_base: str) -> bytes:
|
||||||
|
"""Merge helm index.yaml files with per-member URL rewriting.
|
||||||
|
|
||||||
|
Priority is determined by position in member_names: earlier members win
|
||||||
|
when the same chart name + version appears in multiple remotes.
|
||||||
|
"""
|
||||||
|
merged_entries: dict[str, list] = {}
|
||||||
|
|
||||||
|
for raw_data, member_name, member_cfg in zip(raw_indexes, member_names, member_configs):
|
||||||
|
base_url = member_cfg.get("base_url", "").rstrip("/")
|
||||||
|
rewritten, _ = _helm.resolve_content(raw_data, "index.yaml", "index.yaml", base_url, proxy_base, member_name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
index = yaml.safe_load(rewritten)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Virtual: failed to parse index.yaml from member '{member_name}': {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
for chart_name, versions in (index.get("entries") or {}).items():
|
||||||
|
if chart_name not in merged_entries:
|
||||||
|
merged_entries[chart_name] = list(versions)
|
||||||
|
else:
|
||||||
|
existing = {(v.get("name"), v.get("version")) for v in merged_entries[chart_name]}
|
||||||
|
for version_entry in versions:
|
||||||
|
key = (version_entry.get("name"), version_entry.get("version"))
|
||||||
|
if key not in existing:
|
||||||
|
merged_entries[chart_name].append(version_entry)
|
||||||
|
existing.add(key)
|
||||||
|
|
||||||
|
merged = {
|
||||||
|
"apiVersion": "v1",
|
||||||
|
"entries": merged_entries,
|
||||||
|
"generated": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S.000Z"),
|
||||||
|
}
|
||||||
|
return yaml.dump(merged, Dumper=_HelmDumper, default_flow_style=False, allow_unicode=True).encode()
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class _VirtualHandler(Protocol):
|
||||||
|
def accepts_path(self, path: str) -> bool: ...
|
||||||
|
def merge(self, raw_indexes: list[bytes], member_names: list[str], member_configs: list[dict], proxy_base: str) -> bytes: ...
|
||||||
|
def path_error(self) -> str: ...
|
||||||
|
|
||||||
|
|
||||||
|
class _HelmHandler:
|
||||||
|
def accepts_path(self, path: str) -> bool:
|
||||||
|
return path == "index.yaml"
|
||||||
|
|
||||||
|
def merge(self, raw_indexes: list[bytes], member_names: list[str], member_configs: list[dict], proxy_base: str) -> bytes:
|
||||||
|
return _merge_helm_indexes(raw_indexes, member_names, member_configs, proxy_base)
|
||||||
|
|
||||||
|
def path_error(self) -> str:
|
||||||
|
return "Virtual helm repositories only serve index.yaml; chart tarballs are served directly by member remotes"
|
||||||
|
|
||||||
|
|
||||||
|
_HANDLERS: dict[str, _VirtualHandler] = {
|
||||||
|
"helm": _HelmHandler(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def handle(request: Request, virtual_name: str, path: str, storage, cache, config) -> Response:
|
||||||
|
virtual_cfg = config.get_remote_config(virtual_name)
|
||||||
|
if not virtual_cfg:
|
||||||
|
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")
|
||||||
|
handler = _HANDLERS.get(package)
|
||||||
|
if handler is None:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Virtual repositories with package '{package}' are not yet supported")
|
||||||
|
|
||||||
|
if not handler.accepts_path(path):
|
||||||
|
raise HTTPException(status_code=404, detail=handler.path_error())
|
||||||
|
|
||||||
|
members = virtual_cfg.get("members", [])
|
||||||
|
if not members:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Virtual repository '{virtual_name}' has no members configured")
|
||||||
|
|
||||||
|
virtual_key = storage.get_object_key(virtual_name, path)
|
||||||
|
|
||||||
|
if cache.is_index_valid(virtual_name, path) and storage.exists(virtual_key):
|
||||||
|
data = storage.download_object(virtual_key)
|
||||||
|
logger.info(f"Virtual HIT: {virtual_name}/{path}")
|
||||||
|
return Response(content=data, media_type="text/yaml")
|
||||||
|
|
||||||
|
# Resolve configs first (config reads are sync/cheap)
|
||||||
|
member_entries = []
|
||||||
|
for member_name in members:
|
||||||
|
member_cfg = config.get_remote_config(member_name)
|
||||||
|
if not member_cfg:
|
||||||
|
logger.warning(f"Virtual '{virtual_name}': member '{member_name}' not found in config, skipping")
|
||||||
|
continue
|
||||||
|
member_entries.append((member_name, member_cfg))
|
||||||
|
|
||||||
|
# Fetch all member indexes in parallel; asyncio.gather preserves input order
|
||||||
|
proxy_base = str(request.base_url).rstrip("/")
|
||||||
|
t_fetch = time.perf_counter()
|
||||||
|
results = await asyncio.gather(*[_get_member_index(name, cfg, path, storage, cache) for name, cfg in member_entries])
|
||||||
|
fetch_ms = int((time.perf_counter() - t_fetch) * 1000)
|
||||||
|
|
||||||
|
raw_indexes: list[bytes] = []
|
||||||
|
used_members: list[str] = []
|
||||||
|
used_configs: list[dict] = []
|
||||||
|
min_ttl: int | None = None
|
||||||
|
|
||||||
|
for member_name, member_cfg, member_ttl, raw_data in results:
|
||||||
|
if min_ttl is None or member_ttl < min_ttl:
|
||||||
|
min_ttl = member_ttl
|
||||||
|
if raw_data is None:
|
||||||
|
logger.warning(f"Virtual '{virtual_name}': skipping unreachable member '{member_name}'")
|
||||||
|
continue
|
||||||
|
raw_indexes.append(raw_data)
|
||||||
|
used_members.append(member_name)
|
||||||
|
used_configs.append(member_cfg)
|
||||||
|
|
||||||
|
if not raw_indexes:
|
||||||
|
raise HTTPException(status_code=502, detail=f"Virtual repository '{virtual_name}': no member indices could be fetched")
|
||||||
|
|
||||||
|
if min_ttl is None:
|
||||||
|
min_ttl = 3600
|
||||||
|
|
||||||
|
t_merge = time.perf_counter()
|
||||||
|
merged = handler.merge(raw_indexes, used_members, used_configs, proxy_base)
|
||||||
|
merge_ms = int((time.perf_counter() - t_merge) * 1000)
|
||||||
|
|
||||||
|
try:
|
||||||
|
t_store = time.perf_counter()
|
||||||
|
storage.upload(virtual_key, merged)
|
||||||
|
cache.mark_index_cached(virtual_name, path, min_ttl)
|
||||||
|
store_ms = int((time.perf_counter() - t_store) * 1000)
|
||||||
|
logger.info(
|
||||||
|
f"Virtual MISS: {virtual_name}/{path} rebuilt from {used_members} "
|
||||||
|
f"(fetch={fetch_ms}ms merge={merge_ms}ms store={store_ms}ms ttl={min_ttl}s)"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Virtual: failed to store merged index for '{virtual_name}': {e}")
|
||||||
|
|
||||||
|
return Response(content=merged, media_type="text/yaml")
|
||||||
@@ -4,6 +4,21 @@ import os
|
|||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
|
_TYPE_KEYS = ("remote", "virtual", "local")
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_loaded(raw: dict) -> dict:
|
||||||
|
"""Convert {remote: {...}, virtual: {...}, local: {...}} into {remotes: {name: {type: ..., ...}}}."""
|
||||||
|
remotes = {}
|
||||||
|
for type_key in _TYPE_KEYS:
|
||||||
|
for name, cfg in (raw.get(type_key) or {}).items():
|
||||||
|
remotes[name] = {"type": type_key, **cfg}
|
||||||
|
result = {k: v for k, v in raw.items() if k not in _TYPE_KEYS}
|
||||||
|
if remotes:
|
||||||
|
result["remotes"] = remotes
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
_PACKAGE_MUTABLE_PATTERNS: dict[str, list[str]] = {
|
_PACKAGE_MUTABLE_PATTERNS: dict[str, list[str]] = {
|
||||||
"alpine": [
|
"alpine": [
|
||||||
r"APKINDEX\.tar\.gz$",
|
r"APKINDEX\.tar\.gz$",
|
||||||
@@ -41,8 +56,10 @@ class ConfigManager:
|
|||||||
try:
|
try:
|
||||||
with open(path) as f:
|
with open(path) as f:
|
||||||
if path.endswith((".yaml", ".yml")):
|
if path.endswith((".yaml", ".yml")):
|
||||||
return yaml.safe_load(f) or {}
|
raw = yaml.safe_load(f) or {}
|
||||||
return json.load(f)
|
else:
|
||||||
|
raw = json.load(f)
|
||||||
|
return _normalize_loaded(raw)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ try:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
__version__ = "dev"
|
__version__ = "dev"
|
||||||
|
|
||||||
from .artifact import discovery, flush, local, proxy
|
from .artifact import discovery, flush, local, proxy, virtual
|
||||||
from .artifact import docker as docker_handler
|
from .artifact import docker as docker_handler
|
||||||
from .cache import RedisCache
|
from .cache import RedisCache
|
||||||
from .config import ConfigManager
|
from .config import ConfigManager
|
||||||
@@ -89,6 +89,11 @@ async def docker_v2_proxy(request: Request, remote_name: str, path: str):
|
|||||||
return await docker_handler.proxy(request, remote_name, path, storage, cache, config, metrics)
|
return await docker_handler.proxy(request, remote_name, path, storage, cache, config, metrics)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/virtual/{virtual_name}/{path:path}")
|
||||||
|
async def get_virtual_artifact(request: Request, virtual_name: str, path: str):
|
||||||
|
return await virtual.handle(request, virtual_name, path, storage, cache, config)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/remote/{remote_name}/{path:path}")
|
@app.get("/api/v1/remote/{remote_name}/{path:path}")
|
||||||
async def get_artifact(request: Request, remote_name: str, path: str):
|
async def get_artifact(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)
|
||||||
|
|||||||
@@ -116,6 +116,28 @@ TEST_REMOTES = {
|
|||||||
"quarantine_days": 3,
|
"quarantine_days": 3,
|
||||||
"cache": {"immutable_ttl": 0, "mutable_ttl": 0},
|
"cache": {"immutable_ttl": 0, "mutable_ttl": 0},
|
||||||
},
|
},
|
||||||
|
"helm-member-2": {
|
||||||
|
"base_url": "https://charts.example.com",
|
||||||
|
"type": "remote",
|
||||||
|
"package": "helm",
|
||||||
|
"immutable_patterns": [r"\.tgz$"],
|
||||||
|
"cache": {"immutable_ttl": 0, "mutable_ttl": 1800},
|
||||||
|
},
|
||||||
|
"helm-virtual-test": {
|
||||||
|
"type": "virtual",
|
||||||
|
"package": "helm",
|
||||||
|
"members": ["helm-test", "helm-member-2"],
|
||||||
|
},
|
||||||
|
"unsupported-virtual-test": {
|
||||||
|
"type": "virtual",
|
||||||
|
"package": "rpm",
|
||||||
|
"members": ["rpm-test"],
|
||||||
|
},
|
||||||
|
"empty-virtual-test": {
|
||||||
|
"type": "virtual",
|
||||||
|
"package": "helm",
|
||||||
|
"members": [],
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+112
-54
@@ -10,11 +10,11 @@ from artifactapi.config import ConfigManager
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def make_config(tmp_path):
|
def make_config(tmp_path):
|
||||||
"""Factory: write a remotes dict to a temp YAML and return a ConfigManager."""
|
"""Factory: write a remote dict to a temp YAML and return a ConfigManager."""
|
||||||
|
|
||||||
def _make(remotes_dict):
|
def _make(remotes_dict):
|
||||||
cfg_file = tmp_path / "remotes.yaml"
|
cfg_file = tmp_path / "remotes.yaml"
|
||||||
cfg_file.write_text(yaml.dump({"remotes": remotes_dict}))
|
cfg_file.write_text(yaml.dump({"remote": remotes_dict}))
|
||||||
return ConfigManager(str(cfg_file))
|
return ConfigManager(str(cfg_file))
|
||||||
|
|
||||||
return _make
|
return _make
|
||||||
@@ -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": {"type": "remote", "package": "alpine", "base_url": "https://x.com"}})
|
cfg = make_config({"r": {"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": {"type": "remote", "package": "rpm", "base_url": "https://x.com"}})
|
cfg = make_config({"r": {"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": {"type": "remote", "package": "docker", "base_url": "https://x.com"}})
|
cfg = make_config({"r": {"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": {"type": "remote", "package": "generic", "base_url": "https://x.com"}})
|
cfg = make_config({"r": {"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,19 +52,18 @@ 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": {"type": "remote", "base_url": "https://x.com"}})
|
cfg = make_config({"r": {"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": {"type": "remote", "package": "deb", "base_url": "https://x.com"}})
|
cfg = make_config({"r": {"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):
|
||||||
cfg = make_config(
|
cfg = make_config(
|
||||||
{
|
{
|
||||||
"r": {
|
"r": {
|
||||||
"type": "remote",
|
|
||||||
"package": "alpine",
|
"package": "alpine",
|
||||||
"base_url": "https://x.com",
|
"base_url": "https://x.com",
|
||||||
"mutable_patterns": [r"custom\.json$"],
|
"mutable_patterns": [r"custom\.json$"],
|
||||||
@@ -81,7 +80,6 @@ class TestGetMutablePatterns:
|
|||||||
cfg = make_config(
|
cfg = make_config(
|
||||||
{
|
{
|
||||||
"r": {
|
"r": {
|
||||||
"type": "remote",
|
|
||||||
"package": "alpine",
|
"package": "alpine",
|
||||||
"base_url": "https://x.com",
|
"base_url": "https://x.com",
|
||||||
"mutable_patterns": [],
|
"mutable_patterns": [],
|
||||||
@@ -95,7 +93,6 @@ class TestGetMutablePatterns:
|
|||||||
cfg = make_config(
|
cfg = make_config(
|
||||||
{
|
{
|
||||||
"r": {
|
"r": {
|
||||||
"type": "remote",
|
|
||||||
"package": "alpine",
|
"package": "alpine",
|
||||||
"base_url": "https://x.com",
|
"base_url": "https://x.com",
|
||||||
"mutable_patterns": [existing],
|
"mutable_patterns": [existing],
|
||||||
@@ -109,7 +106,6 @@ class TestGetMutablePatterns:
|
|||||||
cfg = make_config(
|
cfg = make_config(
|
||||||
{
|
{
|
||||||
"r": {
|
"r": {
|
||||||
"type": "remote",
|
|
||||||
"package": "generic",
|
"package": "generic",
|
||||||
"base_url": "https://x.com",
|
"base_url": "https://x.com",
|
||||||
"mutable_patterns": [r"meta\.json$", r"index\.yaml$"],
|
"mutable_patterns": [r"meta\.json$", r"index\.yaml$"],
|
||||||
@@ -122,7 +118,6 @@ class TestGetMutablePatterns:
|
|||||||
cfg = make_config(
|
cfg = make_config(
|
||||||
{
|
{
|
||||||
"r": {
|
"r": {
|
||||||
"type": "remote",
|
|
||||||
"package": "rpm",
|
"package": "rpm",
|
||||||
"base_url": "https://x.com",
|
"base_url": "https://x.com",
|
||||||
"mutable_patterns": [r"custom-meta\.xml$"],
|
"mutable_patterns": [r"custom-meta\.xml$"],
|
||||||
@@ -134,7 +129,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": {"type": "remote", "package": "npm", "base_url": "https://x.com"}})
|
cfg = make_config({"r": {"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):
|
||||||
@@ -143,7 +138,6 @@ class TestGetMutablePatterns:
|
|||||||
cfg = make_config(
|
cfg = make_config(
|
||||||
{
|
{
|
||||||
"r": {
|
"r": {
|
||||||
"type": "remote",
|
|
||||||
"package": "npm",
|
"package": "npm",
|
||||||
"base_url": "https://x.com",
|
"base_url": "https://x.com",
|
||||||
"mutable_patterns": [r"^(?!.*\.tgz$).*"],
|
"mutable_patterns": [r"^(?!.*\.tgz$).*"],
|
||||||
@@ -155,14 +149,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": {"type": "remote", "package": "helm", "base_url": "https://helm.example.com"}})
|
cfg = make_config({"r": {"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": {"type": "remote", "package": "helm", "base_url": "https://helm.example.com"}})
|
cfg = make_config({"r": {"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)
|
||||||
@@ -174,7 +168,6 @@ class TestGetMutablePatterns:
|
|||||||
cfg = make_config(
|
cfg = make_config(
|
||||||
{
|
{
|
||||||
"r": {
|
"r": {
|
||||||
"type": "remote",
|
|
||||||
"package": "npm",
|
"package": "npm",
|
||||||
"base_url": "https://x.com",
|
"base_url": "https://x.com",
|
||||||
"mutable_patterns": [r"^(?!.*\.tgz$).*"],
|
"mutable_patterns": [r"^(?!.*\.tgz$).*"],
|
||||||
@@ -196,7 +189,6 @@ class TestGetImmutablePatterns:
|
|||||||
cfg = make_config(
|
cfg = make_config(
|
||||||
{
|
{
|
||||||
"r": {
|
"r": {
|
||||||
"type": "remote",
|
|
||||||
"package": "generic",
|
"package": "generic",
|
||||||
"base_url": "https://x.com",
|
"base_url": "https://x.com",
|
||||||
"immutable_patterns": [r".*\.tar\.gz$"],
|
"immutable_patterns": [r".*\.tar\.gz$"],
|
||||||
@@ -210,7 +202,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": {"type": "remote", "package": "generic", "base_url": "https://x.com"}})
|
cfg = make_config({"r": {"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):
|
||||||
@@ -218,7 +210,6 @@ class TestGetImmutablePatterns:
|
|||||||
cfg = make_config(
|
cfg = make_config(
|
||||||
{
|
{
|
||||||
"r": {
|
"r": {
|
||||||
"type": "remote",
|
|
||||||
"package": "rpm",
|
"package": "rpm",
|
||||||
"base_url": "https://x.com",
|
"base_url": "https://x.com",
|
||||||
"immutable_patterns": patterns,
|
"immutable_patterns": patterns,
|
||||||
@@ -231,7 +222,6 @@ class TestGetImmutablePatterns:
|
|||||||
cfg = make_config(
|
cfg = make_config(
|
||||||
{
|
{
|
||||||
"r": {
|
"r": {
|
||||||
"type": "remote",
|
|
||||||
"package": "generic",
|
"package": "generic",
|
||||||
"base_url": "https://x.com",
|
"base_url": "https://x.com",
|
||||||
"immutable_patterns": [r".*\.tar\.gz$"],
|
"immutable_patterns": [r".*\.tar\.gz$"],
|
||||||
@@ -247,7 +237,6 @@ class TestGetImmutablePatterns:
|
|||||||
cfg = make_config(
|
cfg = make_config(
|
||||||
{
|
{
|
||||||
"r": {
|
"r": {
|
||||||
"type": "remote",
|
|
||||||
"package": "generic",
|
"package": "generic",
|
||||||
"base_url": "https://x.com",
|
"base_url": "https://x.com",
|
||||||
"immutable_patterns": [r".*\.tar\.gz$"],
|
"immutable_patterns": [r".*\.tar\.gz$"],
|
||||||
@@ -270,7 +259,6 @@ class TestGetUserMutablePatterns:
|
|||||||
cfg = make_config(
|
cfg = make_config(
|
||||||
{
|
{
|
||||||
"r": {
|
"r": {
|
||||||
"type": "remote",
|
|
||||||
"package": "alpine",
|
"package": "alpine",
|
||||||
"base_url": "https://x.com",
|
"base_url": "https://x.com",
|
||||||
"mutable_patterns": [r"custom\.json$"],
|
"mutable_patterns": [r"custom\.json$"],
|
||||||
@@ -281,7 +269,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": {"type": "remote", "package": "alpine", "base_url": "https://x.com"}})
|
cfg = make_config({"r": {"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 +277,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": {"type": "remote", "package": "generic", "base_url": "https://x.com"}})
|
cfg = make_config({"r": {"package": "generic", "base_url": "https://x.com"}})
|
||||||
assert cfg.get_user_mutable_patterns("r") == []
|
assert cfg.get_user_mutable_patterns("r") == []
|
||||||
|
|
||||||
|
|
||||||
@@ -303,7 +291,6 @@ class TestGetCacheConfig:
|
|||||||
cfg = make_config(
|
cfg = make_config(
|
||||||
{
|
{
|
||||||
"r": {
|
"r": {
|
||||||
"type": "remote",
|
|
||||||
"package": "generic",
|
"package": "generic",
|
||||||
"base_url": "https://x.com",
|
"base_url": "https://x.com",
|
||||||
"cache": {"immutable_ttl": 0, "mutable_ttl": 7200},
|
"cache": {"immutable_ttl": 0, "mutable_ttl": 7200},
|
||||||
@@ -317,7 +304,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": {"type": "remote", "package": "generic", "base_url": "https://x.com"}})
|
cfg = make_config({"r": {"package": "generic", "base_url": "https://x.com"}})
|
||||||
assert cfg.get_cache_config("r") == {}
|
assert cfg.get_cache_config("r") == {}
|
||||||
|
|
||||||
|
|
||||||
@@ -329,11 +316,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": {"type": "remote", "package": "generic", "base_url": "https://x.com"}}}))
|
cfg_file.write_text(yaml.dump({"remote": {"repo-a": {"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": {"type": "remote", "package": "generic", "base_url": "https://y.com"}}}))
|
cfg_file.write_text(yaml.dump({"remote": {"repo-b": {"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 +331,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": {"type": "remote", "package": "generic", "base_url": "https://x.com"}}}))
|
cfg_file.write_text(yaml.dump({"remote": {"repo-a": {"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 +347,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": {"type": "remote", "package": "generic", "base_url": "https://x.com"}})
|
cfg = make_config({"r": {"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
|
||||||
@@ -375,7 +362,6 @@ class TestGetQuarantineConfig:
|
|||||||
cfg = make_config(
|
cfg = make_config(
|
||||||
{
|
{
|
||||||
"r": {
|
"r": {
|
||||||
"type": "remote",
|
|
||||||
"package": "generic",
|
"package": "generic",
|
||||||
"base_url": "https://x.com",
|
"base_url": "https://x.com",
|
||||||
"quarantine_new": True,
|
"quarantine_new": True,
|
||||||
@@ -391,7 +377,6 @@ class TestGetQuarantineConfig:
|
|||||||
cfg = make_config(
|
cfg = make_config(
|
||||||
{
|
{
|
||||||
"r": {
|
"r": {
|
||||||
"type": "remote",
|
|
||||||
"package": "generic",
|
"package": "generic",
|
||||||
"base_url": "https://x.com",
|
"base_url": "https://x.com",
|
||||||
"quarantine_new": False,
|
"quarantine_new": False,
|
||||||
@@ -407,7 +392,6 @@ class TestGetQuarantineConfig:
|
|||||||
cfg = make_config(
|
cfg = make_config(
|
||||||
{
|
{
|
||||||
"r": {
|
"r": {
|
||||||
"type": "remote",
|
|
||||||
"package": "generic",
|
"package": "generic",
|
||||||
"base_url": "https://x.com",
|
"base_url": "https://x.com",
|
||||||
"quarantine_new": True,
|
"quarantine_new": True,
|
||||||
@@ -426,20 +410,20 @@ class TestGetQuarantineConfig:
|
|||||||
|
|
||||||
|
|
||||||
def _remote(base_url: str = "https://x.com") -> dict:
|
def _remote(base_url: str = "https://x.com") -> dict:
|
||||||
return {"type": "remote", "package": "generic", "base_url": base_url}
|
return {"package": "generic", "base_url": base_url}
|
||||||
|
|
||||||
|
|
||||||
class TestConfigDirMode:
|
class TestConfigDirMode:
|
||||||
def test_loads_all_yaml_files(self, tmp_path):
|
def test_loads_all_yaml_files(self, tmp_path):
|
||||||
(tmp_path / "a.yaml").write_text(yaml.dump({"remotes": {"repo-a": _remote()}}))
|
(tmp_path / "a.yaml").write_text(yaml.dump({"remote": {"repo-a": _remote()}}))
|
||||||
(tmp_path / "b.yaml").write_text(yaml.dump({"remotes": {"repo-b": _remote("https://y.com")}}))
|
(tmp_path / "b.yaml").write_text(yaml.dump({"remote": {"repo-b": _remote("https://y.com")}}))
|
||||||
cfg = ConfigManager(str(tmp_path))
|
cfg = ConfigManager(str(tmp_path))
|
||||||
assert "repo-a" in cfg.config["remotes"]
|
assert "repo-a" in cfg.config["remotes"]
|
||||||
assert "repo-b" in cfg.config["remotes"]
|
assert "repo-b" in cfg.config["remotes"]
|
||||||
|
|
||||||
def test_later_file_overrides_earlier_on_same_key(self, tmp_path):
|
def test_later_file_overrides_earlier_on_same_key(self, tmp_path):
|
||||||
(tmp_path / "a.yaml").write_text(yaml.dump({"remotes": {"r": _remote("https://first.com")}}))
|
(tmp_path / "a.yaml").write_text(yaml.dump({"remote": {"r": _remote("https://first.com")}}))
|
||||||
(tmp_path / "b.yaml").write_text(yaml.dump({"remotes": {"r": _remote("https://second.com")}}))
|
(tmp_path / "b.yaml").write_text(yaml.dump({"remote": {"r": _remote("https://second.com")}}))
|
||||||
cfg = ConfigManager(str(tmp_path))
|
cfg = ConfigManager(str(tmp_path))
|
||||||
assert cfg.config["remotes"]["r"]["base_url"] == "https://second.com"
|
assert cfg.config["remotes"]["r"]["base_url"] == "https://second.com"
|
||||||
|
|
||||||
@@ -449,18 +433,18 @@ class TestConfigDirMode:
|
|||||||
|
|
||||||
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")
|
||||||
(tmp_path / "a.yaml").write_text(yaml.dump({"remotes": {"repo-a": _remote()}}))
|
(tmp_path / "a.yaml").write_text(yaml.dump({"remote": {"repo-a": _remote()}}))
|
||||||
cfg = ConfigManager(str(tmp_path))
|
cfg = ConfigManager(str(tmp_path))
|
||||||
assert list(cfg.config["remotes"].keys()) == ["repo-a"]
|
assert list(cfg.config["remotes"].keys()) == ["repo-a"]
|
||||||
|
|
||||||
def test_reload_picks_up_new_file(self, tmp_path):
|
def test_reload_picks_up_new_file(self, tmp_path):
|
||||||
(tmp_path / "a.yaml").write_text(yaml.dump({"remotes": {"repo-a": _remote()}}))
|
(tmp_path / "a.yaml").write_text(yaml.dump({"remote": {"repo-a": _remote()}}))
|
||||||
cfg = ConfigManager(str(tmp_path))
|
cfg = ConfigManager(str(tmp_path))
|
||||||
assert "repo-a" in cfg.config["remotes"]
|
assert "repo-a" in cfg.config["remotes"]
|
||||||
assert "repo-b" not in cfg.config["remotes"]
|
assert "repo-b" not in cfg.config["remotes"]
|
||||||
|
|
||||||
new_file = tmp_path / "b.yaml"
|
new_file = tmp_path / "b.yaml"
|
||||||
new_file.write_text(yaml.dump({"remotes": {"repo-b": _remote("https://y.com")}}))
|
new_file.write_text(yaml.dump({"remote": {"repo-b": _remote("https://y.com")}}))
|
||||||
future_mtime = cfg._last_modified + 1
|
future_mtime = cfg._last_modified + 1
|
||||||
os.utime(str(new_file), (future_mtime, future_mtime))
|
os.utime(str(new_file), (future_mtime, future_mtime))
|
||||||
|
|
||||||
@@ -479,9 +463,9 @@ class TestConfigDirKey:
|
|||||||
def test_merges_remotes_from_config_dir(self, tmp_path):
|
def test_merges_remotes_from_config_dir(self, tmp_path):
|
||||||
conf_d = tmp_path / "conf.d"
|
conf_d = tmp_path / "conf.d"
|
||||||
conf_d.mkdir()
|
conf_d.mkdir()
|
||||||
(conf_d / "remotes.yaml").write_text(yaml.dump({"remotes": {"repo-extra": _remote("https://extra.com")}}))
|
(conf_d / "remotes.yaml").write_text(yaml.dump({"remote": {"repo-extra": _remote("https://extra.com")}}))
|
||||||
main = tmp_path / "config.yaml"
|
main = tmp_path / "config.yaml"
|
||||||
main.write_text(yaml.dump({"config_dir": str(conf_d), "remotes": {"repo-main": _remote()}}))
|
main.write_text(yaml.dump({"config_dir": str(conf_d), "remote": {"repo-main": _remote()}}))
|
||||||
cfg = ConfigManager(str(main))
|
cfg = ConfigManager(str(main))
|
||||||
assert "repo-main" in cfg.config["remotes"]
|
assert "repo-main" in cfg.config["remotes"]
|
||||||
assert "repo-extra" in cfg.config["remotes"]
|
assert "repo-extra" in cfg.config["remotes"]
|
||||||
@@ -489,9 +473,9 @@ class TestConfigDirKey:
|
|||||||
def test_relative_config_dir_resolved_from_main_file(self, tmp_path):
|
def test_relative_config_dir_resolved_from_main_file(self, tmp_path):
|
||||||
conf_d = tmp_path / "conf.d"
|
conf_d = tmp_path / "conf.d"
|
||||||
conf_d.mkdir()
|
conf_d.mkdir()
|
||||||
(conf_d / "r.yaml").write_text(yaml.dump({"remotes": {"repo-a": _remote()}}))
|
(conf_d / "r.yaml").write_text(yaml.dump({"remote": {"repo-a": _remote()}}))
|
||||||
main = tmp_path / "config.yaml"
|
main = tmp_path / "config.yaml"
|
||||||
main.write_text(yaml.dump({"config_dir": "conf.d", "remotes": {}}))
|
main.write_text(yaml.dump({"config_dir": "conf.d"}))
|
||||||
cfg = ConfigManager(str(main))
|
cfg = ConfigManager(str(main))
|
||||||
assert "repo-a" in cfg.config["remotes"]
|
assert "repo-a" in cfg.config["remotes"]
|
||||||
|
|
||||||
@@ -499,16 +483,16 @@ class TestConfigDirKey:
|
|||||||
conf_d = tmp_path / "conf.d"
|
conf_d = tmp_path / "conf.d"
|
||||||
conf_d.mkdir()
|
conf_d.mkdir()
|
||||||
main = tmp_path / "config.yaml"
|
main = tmp_path / "config.yaml"
|
||||||
main.write_text(yaml.dump({"config_dir": str(conf_d), "remotes": {}}))
|
main.write_text(yaml.dump({"config_dir": str(conf_d), "remote": {}}))
|
||||||
cfg = ConfigManager(str(main))
|
cfg = ConfigManager(str(main))
|
||||||
assert "config_dir" not in cfg.config
|
assert "config_dir" not in cfg.config
|
||||||
|
|
||||||
def test_dir_remote_overrides_main_file_remote(self, tmp_path):
|
def test_dir_remote_overrides_main_file_remote(self, tmp_path):
|
||||||
conf_d = tmp_path / "conf.d"
|
conf_d = tmp_path / "conf.d"
|
||||||
conf_d.mkdir()
|
conf_d.mkdir()
|
||||||
(conf_d / "override.yaml").write_text(yaml.dump({"remotes": {"r": _remote("https://new.com")}}))
|
(conf_d / "override.yaml").write_text(yaml.dump({"remote": {"r": _remote("https://new.com")}}))
|
||||||
main = tmp_path / "config.yaml"
|
main = tmp_path / "config.yaml"
|
||||||
main.write_text(yaml.dump({"config_dir": str(conf_d), "remotes": {"r": _remote("https://old.com")}}))
|
main.write_text(yaml.dump({"config_dir": str(conf_d), "remote": {"r": _remote("https://old.com")}}))
|
||||||
cfg = ConfigManager(str(main))
|
cfg = ConfigManager(str(main))
|
||||||
assert cfg.config["remotes"]["r"]["base_url"] == "https://new.com"
|
assert cfg.config["remotes"]["r"]["base_url"] == "https://new.com"
|
||||||
|
|
||||||
@@ -516,7 +500,7 @@ class TestConfigDirKey:
|
|||||||
conf_d = tmp_path / "conf.d"
|
conf_d = tmp_path / "conf.d"
|
||||||
conf_d.mkdir()
|
conf_d.mkdir()
|
||||||
main = tmp_path / "config.yaml"
|
main = tmp_path / "config.yaml"
|
||||||
main.write_text(yaml.dump({"config_dir": str(conf_d), "remotes": {"repo-main": _remote()}}))
|
main.write_text(yaml.dump({"config_dir": str(conf_d), "remote": {"repo-main": _remote()}}))
|
||||||
cfg = ConfigManager(str(main))
|
cfg = ConfigManager(str(main))
|
||||||
assert list(cfg.config["remotes"].keys()) == ["repo-main"]
|
assert list(cfg.config["remotes"].keys()) == ["repo-main"]
|
||||||
|
|
||||||
@@ -524,13 +508,13 @@ class TestConfigDirKey:
|
|||||||
conf_d = tmp_path / "conf.d"
|
conf_d = tmp_path / "conf.d"
|
||||||
conf_d.mkdir()
|
conf_d.mkdir()
|
||||||
dir_file = conf_d / "r.yaml"
|
dir_file = conf_d / "r.yaml"
|
||||||
dir_file.write_text(yaml.dump({"remotes": {"repo-v1": _remote()}}))
|
dir_file.write_text(yaml.dump({"remote": {"repo-v1": _remote()}}))
|
||||||
main = tmp_path / "config.yaml"
|
main = tmp_path / "config.yaml"
|
||||||
main.write_text(yaml.dump({"config_dir": str(conf_d), "remotes": {}}))
|
main.write_text(yaml.dump({"config_dir": str(conf_d), "remote": {}}))
|
||||||
cfg = ConfigManager(str(main))
|
cfg = ConfigManager(str(main))
|
||||||
assert "repo-v1" in cfg.config["remotes"]
|
assert "repo-v1" in cfg.config["remotes"]
|
||||||
|
|
||||||
dir_file.write_text(yaml.dump({"remotes": {"repo-v2": _remote("https://v2.com")}}))
|
dir_file.write_text(yaml.dump({"remote": {"repo-v2": _remote("https://v2.com")}}))
|
||||||
future_mtime = cfg._last_modified + 1
|
future_mtime = cfg._last_modified + 1
|
||||||
os.utime(str(dir_file), (future_mtime, future_mtime))
|
os.utime(str(dir_file), (future_mtime, future_mtime))
|
||||||
|
|
||||||
@@ -538,3 +522,77 @@ class TestConfigDirKey:
|
|||||||
|
|
||||||
assert "repo-v2" in cfg.config["remotes"]
|
assert "repo-v2" in cfg.config["remotes"]
|
||||||
assert "repo-v1" not in cfg.config["remotes"]
|
assert "repo-v1" not in cfg.config["remotes"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# YAML format normalisation — top-level type keys
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestYamlTypeKeys:
|
||||||
|
def test_remote_key_injects_type_remote(self, tmp_path):
|
||||||
|
f = tmp_path / "r.yaml"
|
||||||
|
f.write_text(yaml.dump({"remote": {"my-remote": {"package": "generic", "base_url": "https://x.com"}}}))
|
||||||
|
cfg = ConfigManager(str(f))
|
||||||
|
assert cfg.config["remotes"]["my-remote"]["type"] == "remote"
|
||||||
|
|
||||||
|
def test_virtual_key_injects_type_virtual(self, tmp_path):
|
||||||
|
f = tmp_path / "r.yaml"
|
||||||
|
f.write_text(yaml.dump({"virtual": {"my-virtual": {"package": "helm", "members": ["a", "b"]}}}))
|
||||||
|
cfg = ConfigManager(str(f))
|
||||||
|
assert cfg.config["remotes"]["my-virtual"]["type"] == "virtual"
|
||||||
|
assert cfg.config["remotes"]["my-virtual"]["members"] == ["a", "b"]
|
||||||
|
|
||||||
|
def test_local_key_injects_type_local(self, tmp_path):
|
||||||
|
f = tmp_path / "r.yaml"
|
||||||
|
f.write_text(yaml.dump({"local": {"my-local": {"package": "generic"}}}))
|
||||||
|
cfg = ConfigManager(str(f))
|
||||||
|
assert cfg.config["remotes"]["my-local"]["type"] == "local"
|
||||||
|
|
||||||
|
def test_mixed_file_all_three_types(self, tmp_path):
|
||||||
|
f = tmp_path / "r.yaml"
|
||||||
|
f.write_text(
|
||||||
|
yaml.dump(
|
||||||
|
{
|
||||||
|
"remote": {"r": {"package": "helm", "base_url": "https://helm.example.com"}},
|
||||||
|
"virtual": {"v": {"package": "helm", "members": ["r"]}},
|
||||||
|
"local": {"l": {"package": "generic"}},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
cfg = ConfigManager(str(f))
|
||||||
|
assert cfg.config["remotes"]["r"]["type"] == "remote"
|
||||||
|
assert cfg.config["remotes"]["v"]["type"] == "virtual"
|
||||||
|
assert cfg.config["remotes"]["l"]["type"] == "local"
|
||||||
|
|
||||||
|
def test_type_field_not_required_in_yaml(self, tmp_path):
|
||||||
|
f = tmp_path / "r.yaml"
|
||||||
|
f.write_text(yaml.dump({"remote": {"r": {"package": "alpine", "base_url": "https://x.com"}}}))
|
||||||
|
cfg = ConfigManager(str(f))
|
||||||
|
raw = cfg.config["remotes"]["r"]
|
||||||
|
# type is injected by the loader; the original dict had no type key
|
||||||
|
assert "type" in raw
|
||||||
|
assert raw["type"] == "remote"
|
||||||
|
|
||||||
|
def test_other_fields_preserved_after_normalisation(self, tmp_path):
|
||||||
|
f = tmp_path / "r.yaml"
|
||||||
|
f.write_text(
|
||||||
|
yaml.dump(
|
||||||
|
{
|
||||||
|
"remote": {
|
||||||
|
"r": {
|
||||||
|
"package": "helm",
|
||||||
|
"base_url": "https://helm.example.com",
|
||||||
|
"immutable_patterns": [r"\.tgz$"],
|
||||||
|
"cache": {"immutable_ttl": 0, "mutable_ttl": 1800},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
cfg = ConfigManager(str(f))
|
||||||
|
remote = cfg.config["remotes"]["r"]
|
||||||
|
assert remote["package"] == "helm"
|
||||||
|
assert remote["base_url"] == "https://helm.example.com"
|
||||||
|
assert remote["cache"] == {"immutable_ttl": 0, "mutable_ttl": 1800}
|
||||||
|
assert r"\.tgz$" in remote["immutable_patterns"]
|
||||||
|
|||||||
@@ -0,0 +1,596 @@
|
|||||||
|
"""Unit tests for the virtual repository handler (artifact/virtual.py)."""
|
||||||
|
|
||||||
|
from datetime import UTC, date, datetime
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from artifactapi.artifact.virtual import (
|
||||||
|
_HANDLERS,
|
||||||
|
_get_member_index,
|
||||||
|
_HelmDumper,
|
||||||
|
_HelmHandler,
|
||||||
|
_merge_helm_indexes,
|
||||||
|
_VirtualHandler,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Shared sample data
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_INDEX_A = b"""\
|
||||||
|
apiVersion: v1
|
||||||
|
entries:
|
||||||
|
vault:
|
||||||
|
- name: vault
|
||||||
|
version: "0.27.0"
|
||||||
|
urls:
|
||||||
|
- https://helm.releases.hashicorp.com/vault-0.27.0.tgz
|
||||||
|
consul:
|
||||||
|
- name: consul
|
||||||
|
version: "1.2.0"
|
||||||
|
urls:
|
||||||
|
- https://helm.releases.hashicorp.com/consul-1.2.0.tgz
|
||||||
|
generated: "2023-01-01T00:00:00.000Z"
|
||||||
|
"""
|
||||||
|
|
||||||
|
_INDEX_B = b"""\
|
||||||
|
apiVersion: v1
|
||||||
|
entries:
|
||||||
|
nginx:
|
||||||
|
- name: nginx
|
||||||
|
version: "15.0.0"
|
||||||
|
urls:
|
||||||
|
- https://charts.example.com/nginx-15.0.0.tgz
|
||||||
|
vault:
|
||||||
|
- name: vault
|
||||||
|
version: "0.27.0"
|
||||||
|
urls:
|
||||||
|
- https://charts.example.com/vault-0.27.0.tgz
|
||||||
|
- name: vault
|
||||||
|
version: "0.26.0"
|
||||||
|
urls:
|
||||||
|
- https://charts.example.com/vault-0.26.0.tgz
|
||||||
|
generated: "2023-01-01T00:00:00.000Z"
|
||||||
|
"""
|
||||||
|
|
||||||
|
_INDEX_SIMPLE = b"""\
|
||||||
|
apiVersion: v1
|
||||||
|
entries:
|
||||||
|
mychart:
|
||||||
|
- name: mychart
|
||||||
|
version: "1.0.0"
|
||||||
|
urls:
|
||||||
|
- https://helm.releases.hashicorp.com/mychart-1.0.0.tgz
|
||||||
|
generated: "2023-01-01T00:00:00.000Z"
|
||||||
|
"""
|
||||||
|
|
||||||
|
_CFG_A = {"base_url": "https://helm.releases.hashicorp.com", "cache": {"mutable_ttl": 3600}}
|
||||||
|
_CFG_B = {"base_url": "https://charts.example.com", "cache": {"mutable_ttl": 1800}}
|
||||||
|
|
||||||
|
|
||||||
|
def _identity_resolve(data, *args, **kwargs):
|
||||||
|
return data, None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _HelmDumper — datetime/date YAML serialization
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestHelmDumper:
|
||||||
|
def _dump(self, value):
|
||||||
|
return yaml.dump({"v": value}, Dumper=_HelmDumper)
|
||||||
|
|
||||||
|
def test_datetime_with_tz_includes_Z_suffix(self):
|
||||||
|
dt = datetime(2023, 6, 15, 12, 0, 0, tzinfo=UTC)
|
||||||
|
assert "Z" in self._dump(dt)
|
||||||
|
|
||||||
|
def test_datetime_without_tz_has_no_Z_suffix(self):
|
||||||
|
dt = datetime(2023, 6, 15, 12, 0, 0)
|
||||||
|
assert "Z" not in self._dump(dt)
|
||||||
|
|
||||||
|
def test_datetime_uses_T_separator_not_space(self):
|
||||||
|
dt = datetime(2023, 6, 15, 12, 30, 0, tzinfo=UTC)
|
||||||
|
assert "T12:30:00" in self._dump(dt)
|
||||||
|
|
||||||
|
def test_date_serialized_as_iso_string(self):
|
||||||
|
assert "2023-01-15" in self._dump(date(2023, 1, 15))
|
||||||
|
|
||||||
|
def test_datetime_round_trips_as_string_not_python_datetime(self):
|
||||||
|
dt = datetime(2023, 6, 15, 12, 0, 0, tzinfo=UTC)
|
||||||
|
parsed = yaml.safe_load(self._dump(dt))
|
||||||
|
# yaml.safe_load must not re-parse this as a datetime object
|
||||||
|
assert isinstance(parsed["v"], str)
|
||||||
|
|
||||||
|
def test_date_round_trips_as_string_not_python_date(self):
|
||||||
|
parsed = yaml.safe_load(self._dump(date(2023, 1, 15)))
|
||||||
|
assert isinstance(parsed["v"], str)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _HelmHandler
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestHelmHandler:
|
||||||
|
def setup_method(self):
|
||||||
|
self.handler = _HelmHandler()
|
||||||
|
|
||||||
|
def test_accepts_index_yaml(self):
|
||||||
|
assert self.handler.accepts_path("index.yaml") is True
|
||||||
|
|
||||||
|
def test_rejects_tgz_path(self):
|
||||||
|
assert self.handler.accepts_path("vault-0.27.0.tgz") is False
|
||||||
|
|
||||||
|
def test_rejects_subdirectory_index(self):
|
||||||
|
assert self.handler.accepts_path("charts/index.yaml") is False
|
||||||
|
|
||||||
|
def test_rejects_empty_path(self):
|
||||||
|
assert self.handler.accepts_path("") is False
|
||||||
|
|
||||||
|
def test_path_error_is_non_empty_string(self):
|
||||||
|
msg = self.handler.path_error()
|
||||||
|
assert isinstance(msg, str) and len(msg) > 0
|
||||||
|
|
||||||
|
def test_merge_returns_bytes(self):
|
||||||
|
with patch("artifactapi.artifact.virtual._helm.resolve_content", side_effect=_identity_resolve):
|
||||||
|
result = self.handler.merge([_INDEX_A], ["member-a"], [_CFG_A], "http://proxy.example.com")
|
||||||
|
assert isinstance(result, bytes)
|
||||||
|
|
||||||
|
def test_merge_delegates_to_merge_helm_indexes(self):
|
||||||
|
with patch("artifactapi.artifact.virtual._merge_helm_indexes", return_value=b"merged") as mock_fn:
|
||||||
|
result = self.handler.merge([b"data"], ["m"], [{}], "http://proxy")
|
||||||
|
mock_fn.assert_called_once_with([b"data"], ["m"], [{}], "http://proxy")
|
||||||
|
assert result == b"merged"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _HANDLERS registry
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestHandlersRegistry:
|
||||||
|
def test_helm_handler_is_registered(self):
|
||||||
|
assert "helm" in _HANDLERS
|
||||||
|
assert isinstance(_HANDLERS["helm"], _HelmHandler)
|
||||||
|
|
||||||
|
def test_helm_handler_satisfies_protocol(self):
|
||||||
|
assert isinstance(_HANDLERS["helm"], _VirtualHandler)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _merge_helm_indexes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestMergeHelmIndexes:
|
||||||
|
def _merge(self, raw_indexes, member_names, member_configs, proxy_base="http://proxy.example.com"):
|
||||||
|
with patch("artifactapi.artifact.virtual._helm.resolve_content", side_effect=_identity_resolve):
|
||||||
|
return _merge_helm_indexes(raw_indexes, member_names, member_configs, proxy_base)
|
||||||
|
|
||||||
|
def _parse(self, raw):
|
||||||
|
return yaml.safe_load(raw)
|
||||||
|
|
||||||
|
def test_single_member_all_charts_present(self):
|
||||||
|
index = self._parse(self._merge([_INDEX_A], ["member-a"], [_CFG_A]))
|
||||||
|
assert "vault" in index["entries"]
|
||||||
|
assert "consul" in index["entries"]
|
||||||
|
|
||||||
|
def test_two_members_non_overlapping_charts_all_present(self):
|
||||||
|
index = self._parse(self._merge([_INDEX_A, _INDEX_B], ["member-a", "member-b"], [_CFG_A, _CFG_B]))
|
||||||
|
assert "vault" in index["entries"]
|
||||||
|
assert "consul" in index["entries"]
|
||||||
|
assert "nginx" in index["entries"]
|
||||||
|
|
||||||
|
def test_first_member_wins_on_duplicate_name_and_version(self):
|
||||||
|
index = self._parse(self._merge([_INDEX_A, _INDEX_B], ["member-a", "member-b"], [_CFG_A, _CFG_B]))
|
||||||
|
v027 = next(e for e in index["entries"]["vault"] if e["version"] == "0.27.0")
|
||||||
|
assert "helm.releases.hashicorp.com" in v027["urls"][0]
|
||||||
|
|
||||||
|
def test_different_versions_of_same_chart_both_included(self):
|
||||||
|
index = self._parse(self._merge([_INDEX_A, _INDEX_B], ["member-a", "member-b"], [_CFG_A, _CFG_B]))
|
||||||
|
versions = {e["version"] for e in index["entries"]["vault"]}
|
||||||
|
assert "0.27.0" in versions
|
||||||
|
assert "0.26.0" in versions
|
||||||
|
|
||||||
|
def test_malformed_yaml_from_member_is_skipped(self):
|
||||||
|
index = self._parse(self._merge([_INDEX_A, b"{bad yaml"], ["member-a", "bad"], [_CFG_A, _CFG_B]))
|
||||||
|
assert "vault" in index["entries"]
|
||||||
|
assert "consul" in index["entries"]
|
||||||
|
|
||||||
|
def test_output_has_apiVersion_v1(self):
|
||||||
|
index = self._parse(self._merge([_INDEX_A], ["member-a"], [_CFG_A]))
|
||||||
|
assert index["apiVersion"] == "v1"
|
||||||
|
|
||||||
|
def test_output_has_generated_field(self):
|
||||||
|
index = self._parse(self._merge([_INDEX_A], ["member-a"], [_CFG_A]))
|
||||||
|
assert "generated" in index
|
||||||
|
|
||||||
|
def test_output_is_valid_yaml(self):
|
||||||
|
raw = self._merge([_INDEX_A, _INDEX_B], ["member-a", "member-b"], [_CFG_A, _CFG_B])
|
||||||
|
assert isinstance(yaml.safe_load(raw), dict)
|
||||||
|
|
||||||
|
def test_empty_index_from_member_produces_no_entries(self):
|
||||||
|
empty = b"apiVersion: v1\nentries: {}\ngenerated: '2023-01-01T00:00:00.000Z'\n"
|
||||||
|
index = self._parse(self._merge([empty], ["member-a"], [_CFG_A]))
|
||||||
|
assert index["entries"] == {}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _get_member_index (async)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetMemberIndex:
|
||||||
|
@pytest.fixture
|
||||||
|
def storage(self):
|
||||||
|
m = MagicMock()
|
||||||
|
m.get_object_key.return_value = "member/key/index.yaml"
|
||||||
|
m.exists.return_value = False
|
||||||
|
m.download_object.return_value = b"cached bytes"
|
||||||
|
return m
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def cache(self):
|
||||||
|
m = MagicMock()
|
||||||
|
m.is_index_valid.return_value = False
|
||||||
|
return m
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def member_cfg(self):
|
||||||
|
return {"base_url": "https://helm.releases.hashicorp.com", "cache": {"mutable_ttl": 3600}}
|
||||||
|
|
||||||
|
def _fake_response(self, content=b"upstream bytes"):
|
||||||
|
r = MagicMock()
|
||||||
|
r.content = content
|
||||||
|
r.raise_for_status = MagicMock()
|
||||||
|
return r
|
||||||
|
|
||||||
|
def _patch_httpx(self, response):
|
||||||
|
mock_client_cls = patch("artifactapi.artifact.virtual.httpx.AsyncClient")
|
||||||
|
p = mock_client_cls.start()
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
p.return_value.__aenter__.return_value = mock_client
|
||||||
|
mock_client.get.return_value = response
|
||||||
|
return mock_client_cls, mock_client
|
||||||
|
|
||||||
|
async def test_cache_hit_returns_stored_bytes(self, storage, cache, member_cfg):
|
||||||
|
storage.exists.return_value = True
|
||||||
|
cache.is_index_valid.return_value = True
|
||||||
|
|
||||||
|
_, _, _, raw_data = await _get_member_index("m", member_cfg, "index.yaml", storage, cache)
|
||||||
|
|
||||||
|
assert raw_data == b"cached bytes"
|
||||||
|
|
||||||
|
async def test_cache_hit_does_not_fetch_upstream(self, storage, cache, member_cfg):
|
||||||
|
storage.exists.return_value = True
|
||||||
|
cache.is_index_valid.return_value = True
|
||||||
|
|
||||||
|
with patch("artifactapi.artifact.virtual.httpx.AsyncClient") as mock_cls:
|
||||||
|
await _get_member_index("m", member_cfg, "index.yaml", storage, cache)
|
||||||
|
|
||||||
|
mock_cls.assert_not_called()
|
||||||
|
|
||||||
|
async def test_cache_hit_storage_error_falls_through_to_upstream(self, storage, cache, member_cfg):
|
||||||
|
storage.exists.return_value = True
|
||||||
|
cache.is_index_valid.return_value = True
|
||||||
|
storage.download_object.side_effect = Exception("S3 read error")
|
||||||
|
|
||||||
|
with patch("artifactapi.artifact.virtual.httpx.AsyncClient") as mock_cls:
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_cls.return_value.__aenter__.return_value = mock_client
|
||||||
|
mock_client.get.return_value = self._fake_response(b"fresh bytes")
|
||||||
|
|
||||||
|
_, _, _, raw_data = await _get_member_index("m", member_cfg, "index.yaml", storage, cache)
|
||||||
|
|
||||||
|
assert raw_data == b"fresh bytes"
|
||||||
|
|
||||||
|
async def test_cache_miss_fetches_from_upstream(self, storage, cache, member_cfg):
|
||||||
|
with patch("artifactapi.artifact.virtual.httpx.AsyncClient") as mock_cls:
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_cls.return_value.__aenter__.return_value = mock_client
|
||||||
|
mock_client.get.return_value = self._fake_response()
|
||||||
|
|
||||||
|
_, _, _, raw_data = await _get_member_index("m", member_cfg, "index.yaml", storage, cache)
|
||||||
|
|
||||||
|
assert raw_data == b"upstream bytes"
|
||||||
|
|
||||||
|
async def test_cache_miss_stores_result_in_s3(self, storage, cache, member_cfg):
|
||||||
|
with patch("artifactapi.artifact.virtual.httpx.AsyncClient") as mock_cls:
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_cls.return_value.__aenter__.return_value = mock_client
|
||||||
|
mock_client.get.return_value = self._fake_response()
|
||||||
|
|
||||||
|
await _get_member_index("m", member_cfg, "index.yaml", storage, cache)
|
||||||
|
|
||||||
|
storage.upload.assert_called_once()
|
||||||
|
|
||||||
|
async def test_cache_miss_marks_cache_with_configured_ttl(self, storage, cache, member_cfg):
|
||||||
|
with patch("artifactapi.artifact.virtual.httpx.AsyncClient") as mock_cls:
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_cls.return_value.__aenter__.return_value = mock_client
|
||||||
|
mock_client.get.return_value = self._fake_response()
|
||||||
|
|
||||||
|
await _get_member_index("m", member_cfg, "index.yaml", storage, cache)
|
||||||
|
|
||||||
|
cache.mark_index_cached.assert_called_once_with("m", "index.yaml", 3600)
|
||||||
|
|
||||||
|
async def test_cache_miss_with_auth_sends_basic_auth_header(self, storage, cache):
|
||||||
|
cfg = {
|
||||||
|
"base_url": "https://private.example.com",
|
||||||
|
"username": "user",
|
||||||
|
"password": "pass",
|
||||||
|
"cache": {"mutable_ttl": 3600},
|
||||||
|
}
|
||||||
|
with patch("artifactapi.artifact.virtual.httpx.AsyncClient") as mock_cls:
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_cls.return_value.__aenter__.return_value = mock_client
|
||||||
|
mock_client.get.return_value = self._fake_response()
|
||||||
|
|
||||||
|
await _get_member_index("m", cfg, "index.yaml", storage, cache)
|
||||||
|
|
||||||
|
headers = mock_client.get.call_args.kwargs["headers"]
|
||||||
|
assert "Authorization" in headers
|
||||||
|
assert headers["Authorization"].startswith("Basic ")
|
||||||
|
|
||||||
|
async def test_no_credentials_sends_no_auth_header(self, storage, cache, member_cfg):
|
||||||
|
with patch("artifactapi.artifact.virtual.httpx.AsyncClient") as mock_cls:
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_cls.return_value.__aenter__.return_value = mock_client
|
||||||
|
mock_client.get.return_value = self._fake_response()
|
||||||
|
|
||||||
|
await _get_member_index("m", member_cfg, "index.yaml", storage, cache)
|
||||||
|
|
||||||
|
headers = mock_client.get.call_args.kwargs["headers"]
|
||||||
|
assert "Authorization" not in headers
|
||||||
|
|
||||||
|
async def test_upstream_fetch_failure_returns_none(self, storage, cache, member_cfg):
|
||||||
|
with patch("artifactapi.artifact.virtual.httpx.AsyncClient") as mock_cls:
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_cls.return_value.__aenter__.return_value = mock_client
|
||||||
|
mock_client.get.side_effect = Exception("connection refused")
|
||||||
|
|
||||||
|
_, _, _, raw_data = await _get_member_index("m", member_cfg, "index.yaml", storage, cache)
|
||||||
|
|
||||||
|
assert raw_data is None
|
||||||
|
|
||||||
|
async def test_s3_upload_failure_still_returns_data(self, storage, cache, member_cfg):
|
||||||
|
storage.upload.side_effect = Exception("S3 write error")
|
||||||
|
|
||||||
|
with patch("artifactapi.artifact.virtual.httpx.AsyncClient") as mock_cls:
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_cls.return_value.__aenter__.return_value = mock_client
|
||||||
|
mock_client.get.return_value = self._fake_response()
|
||||||
|
|
||||||
|
_, _, _, raw_data = await _get_member_index("m", member_cfg, "index.yaml", storage, cache)
|
||||||
|
|
||||||
|
assert raw_data == b"upstream bytes"
|
||||||
|
|
||||||
|
async def test_returns_ttl_from_config(self, storage, cache):
|
||||||
|
cfg = {"base_url": "https://example.com", "cache": {"mutable_ttl": 900}}
|
||||||
|
with patch("artifactapi.artifact.virtual.httpx.AsyncClient") as mock_cls:
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_cls.return_value.__aenter__.return_value = mock_client
|
||||||
|
mock_client.get.return_value = self._fake_response()
|
||||||
|
|
||||||
|
_, _, ttl, _ = await _get_member_index("m", cfg, "index.yaml", storage, cache)
|
||||||
|
|
||||||
|
assert ttl == 900
|
||||||
|
|
||||||
|
async def test_defaults_ttl_to_3600_when_not_configured(self, storage, cache):
|
||||||
|
cfg = {"base_url": "https://example.com"}
|
||||||
|
with patch("artifactapi.artifact.virtual.httpx.AsyncClient") as mock_cls:
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_cls.return_value.__aenter__.return_value = mock_client
|
||||||
|
mock_client.get.return_value = self._fake_response()
|
||||||
|
|
||||||
|
_, _, ttl, _ = await _get_member_index("m", cfg, "index.yaml", storage, cache)
|
||||||
|
|
||||||
|
assert ttl == 3600
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Virtual route GET /api/v1/virtual/{name}/{path}
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_storage_v():
|
||||||
|
m = MagicMock()
|
||||||
|
m.get_object_key.return_value = "virtual/helm-virtual-test/index.yaml"
|
||||||
|
m.exists.return_value = False
|
||||||
|
m.download_object.return_value = b"apiVersion: v1\nentries: {}\n"
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_cache_v():
|
||||||
|
m = MagicMock()
|
||||||
|
m.is_index_valid.return_value = False
|
||||||
|
m.available = False
|
||||||
|
m.client = None
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def patched_virtual_deps(mock_storage_v, mock_cache_v):
|
||||||
|
import artifactapi.main as main_mod
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(main_mod, "storage", mock_storage_v),
|
||||||
|
patch.object(main_mod, "cache", mock_cache_v),
|
||||||
|
):
|
||||||
|
yield {"storage": mock_storage_v, "cache": mock_cache_v}
|
||||||
|
|
||||||
|
|
||||||
|
class TestVirtualRoute:
|
||||||
|
def test_unknown_virtual_name_returns_404(self, client, patched_virtual_deps):
|
||||||
|
response = client.get("/api/v1/virtual/no-such-virtual/index.yaml")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
def test_non_virtual_type_returns_400(self, client, patched_virtual_deps):
|
||||||
|
# helm-test is type "remote", not "virtual"
|
||||||
|
response = client.get("/api/v1/virtual/helm-test/index.yaml")
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
def test_unsupported_package_returns_400(self, client, patched_virtual_deps):
|
||||||
|
# unsupported-virtual-test has package "rpm"
|
||||||
|
response = client.get("/api/v1/virtual/unsupported-virtual-test/index.yaml")
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
def test_non_index_path_returns_404(self, client, patched_virtual_deps):
|
||||||
|
response = client.get("/api/v1/virtual/helm-virtual-test/vault-0.27.0.tgz")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
def test_no_members_returns_500(self, client, patched_virtual_deps):
|
||||||
|
response = client.get("/api/v1/virtual/empty-virtual-test/index.yaml")
|
||||||
|
assert response.status_code == 500
|
||||||
|
|
||||||
|
def test_virtual_cache_hit_returns_200(self, client, patched_virtual_deps):
|
||||||
|
deps = patched_virtual_deps
|
||||||
|
deps["storage"].exists.return_value = True
|
||||||
|
deps["cache"].is_index_valid.return_value = True
|
||||||
|
|
||||||
|
response = client.get("/api/v1/virtual/helm-virtual-test/index.yaml")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
def test_virtual_cache_hit_content_type_is_yaml(self, client, patched_virtual_deps):
|
||||||
|
deps = patched_virtual_deps
|
||||||
|
deps["storage"].exists.return_value = True
|
||||||
|
deps["cache"].is_index_valid.return_value = True
|
||||||
|
|
||||||
|
response = client.get("/api/v1/virtual/helm-virtual-test/index.yaml")
|
||||||
|
assert "text/yaml" in response.headers["content-type"]
|
||||||
|
|
||||||
|
def test_virtual_cache_hit_returns_stored_content(self, client, patched_virtual_deps):
|
||||||
|
deps = patched_virtual_deps
|
||||||
|
deps["storage"].exists.return_value = True
|
||||||
|
deps["cache"].is_index_valid.return_value = True
|
||||||
|
deps["storage"].download_object.return_value = b"apiVersion: v1\nentries: {}\n"
|
||||||
|
|
||||||
|
response = client.get("/api/v1/virtual/helm-virtual-test/index.yaml")
|
||||||
|
assert response.content == b"apiVersion: v1\nentries: {}\n"
|
||||||
|
|
||||||
|
def test_virtual_cache_hit_skips_member_fetch(self, client, patched_virtual_deps):
|
||||||
|
deps = patched_virtual_deps
|
||||||
|
deps["storage"].exists.return_value = True
|
||||||
|
deps["cache"].is_index_valid.return_value = True
|
||||||
|
|
||||||
|
with patch("artifactapi.artifact.virtual._get_member_index", new_callable=AsyncMock) as mock_get:
|
||||||
|
client.get("/api/v1/virtual/helm-virtual-test/index.yaml")
|
||||||
|
|
||||||
|
mock_get.assert_not_called()
|
||||||
|
|
||||||
|
def test_cache_miss_returns_200_with_yaml_content_type(self, client, patched_virtual_deps):
|
||||||
|
with (
|
||||||
|
patch("artifactapi.artifact.virtual._get_member_index", new_callable=AsyncMock) as mock_get,
|
||||||
|
patch("artifactapi.artifact.virtual._helm.resolve_content", side_effect=_identity_resolve),
|
||||||
|
):
|
||||||
|
mock_get.return_value = ("helm-test", _CFG_A, 3600, _INDEX_SIMPLE)
|
||||||
|
response = client.get("/api/v1/virtual/helm-virtual-test/index.yaml")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "text/yaml" in response.headers["content-type"]
|
||||||
|
|
||||||
|
def test_cache_miss_response_contains_merged_entries(self, client, patched_virtual_deps):
|
||||||
|
with (
|
||||||
|
patch("artifactapi.artifact.virtual._get_member_index", new_callable=AsyncMock) as mock_get,
|
||||||
|
patch("artifactapi.artifact.virtual._helm.resolve_content", side_effect=_identity_resolve),
|
||||||
|
):
|
||||||
|
mock_get.return_value = ("helm-test", _CFG_A, 3600, _INDEX_SIMPLE)
|
||||||
|
response = client.get("/api/v1/virtual/helm-virtual-test/index.yaml")
|
||||||
|
|
||||||
|
index = yaml.safe_load(response.content)
|
||||||
|
assert "mychart" in index["entries"]
|
||||||
|
|
||||||
|
def test_cache_miss_stores_result_in_s3(self, client, patched_virtual_deps):
|
||||||
|
deps = patched_virtual_deps
|
||||||
|
with (
|
||||||
|
patch("artifactapi.artifact.virtual._get_member_index", new_callable=AsyncMock) as mock_get,
|
||||||
|
patch("artifactapi.artifact.virtual._helm.resolve_content", side_effect=_identity_resolve),
|
||||||
|
):
|
||||||
|
mock_get.return_value = ("helm-test", _CFG_A, 3600, _INDEX_SIMPLE)
|
||||||
|
client.get("/api/v1/virtual/helm-virtual-test/index.yaml")
|
||||||
|
|
||||||
|
deps["storage"].upload.assert_called_once()
|
||||||
|
|
||||||
|
def test_cache_miss_marks_index_cached(self, client, patched_virtual_deps):
|
||||||
|
deps = patched_virtual_deps
|
||||||
|
with (
|
||||||
|
patch("artifactapi.artifact.virtual._get_member_index", new_callable=AsyncMock) as mock_get,
|
||||||
|
patch("artifactapi.artifact.virtual._helm.resolve_content", side_effect=_identity_resolve),
|
||||||
|
):
|
||||||
|
mock_get.return_value = ("helm-test", _CFG_A, 3600, _INDEX_SIMPLE)
|
||||||
|
client.get("/api/v1/virtual/helm-virtual-test/index.yaml")
|
||||||
|
|
||||||
|
deps["cache"].mark_index_cached.assert_called_once()
|
||||||
|
|
||||||
|
def test_cache_miss_uses_min_ttl_across_members(self, client, patched_virtual_deps):
|
||||||
|
deps = patched_virtual_deps
|
||||||
|
with (
|
||||||
|
patch("artifactapi.artifact.virtual._get_member_index", new_callable=AsyncMock) as mock_get,
|
||||||
|
patch("artifactapi.artifact.virtual._helm.resolve_content", side_effect=_identity_resolve),
|
||||||
|
):
|
||||||
|
mock_get.side_effect = [
|
||||||
|
("helm-test", _CFG_A, 3600, _INDEX_SIMPLE),
|
||||||
|
("helm-member-2", _CFG_B, 1800, _INDEX_SIMPLE),
|
||||||
|
]
|
||||||
|
client.get("/api/v1/virtual/helm-virtual-test/index.yaml")
|
||||||
|
|
||||||
|
_, _, ttl = deps["cache"].mark_index_cached.call_args[0]
|
||||||
|
assert ttl == 1800
|
||||||
|
|
||||||
|
def test_all_members_unreachable_returns_502(self, client, patched_virtual_deps):
|
||||||
|
with patch("artifactapi.artifact.virtual._get_member_index", new_callable=AsyncMock) as mock_get:
|
||||||
|
mock_get.return_value = ("helm-test", _CFG_A, 3600, None)
|
||||||
|
response = client.get("/api/v1/virtual/helm-virtual-test/index.yaml")
|
||||||
|
|
||||||
|
assert response.status_code == 502
|
||||||
|
|
||||||
|
def test_one_member_unreachable_still_returns_200(self, client, patched_virtual_deps):
|
||||||
|
with (
|
||||||
|
patch("artifactapi.artifact.virtual._get_member_index", new_callable=AsyncMock) as mock_get,
|
||||||
|
patch("artifactapi.artifact.virtual._helm.resolve_content", side_effect=_identity_resolve),
|
||||||
|
):
|
||||||
|
mock_get.side_effect = [
|
||||||
|
("helm-test", _CFG_A, 3600, _INDEX_SIMPLE),
|
||||||
|
("helm-member-2", _CFG_B, 1800, None),
|
||||||
|
]
|
||||||
|
response = client.get("/api/v1/virtual/helm-virtual-test/index.yaml")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
def test_member_not_in_config_is_skipped(self, client, patched_virtual_deps):
|
||||||
|
import artifactapi.main as main_mod
|
||||||
|
|
||||||
|
real_get = main_mod.config.get_remote_config
|
||||||
|
|
||||||
|
def patched_get(name):
|
||||||
|
return None if name == "helm-member-2" else real_get(name)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("artifactapi.artifact.virtual._get_member_index", new_callable=AsyncMock) as mock_get,
|
||||||
|
patch("artifactapi.artifact.virtual._helm.resolve_content", side_effect=_identity_resolve),
|
||||||
|
patch.object(main_mod.config, "get_remote_config", side_effect=patched_get),
|
||||||
|
):
|
||||||
|
mock_get.return_value = ("helm-test", _CFG_A, 3600, _INDEX_SIMPLE)
|
||||||
|
response = client.get("/api/v1/virtual/helm-virtual-test/index.yaml")
|
||||||
|
|
||||||
|
# only helm-test was available — should succeed
|
||||||
|
assert response.status_code == 200
|
||||||
|
mock_get.assert_called_once()
|
||||||
|
|
||||||
|
def test_s3_store_failure_still_returns_200(self, client, patched_virtual_deps):
|
||||||
|
deps = patched_virtual_deps
|
||||||
|
deps["storage"].upload.side_effect = Exception("S3 write error")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("artifactapi.artifact.virtual._get_member_index", new_callable=AsyncMock) as mock_get,
|
||||||
|
patch("artifactapi.artifact.virtual._helm.resolve_content", side_effect=_identity_resolve),
|
||||||
|
):
|
||||||
|
mock_get.return_value = ("helm-test", _CFG_A, 3600, _INDEX_SIMPLE)
|
||||||
|
response = client.get("/api/v1/virtual/helm-virtual-test/index.yaml")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
Reference in New Issue
Block a user