Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 34160032fc |
@@ -70,11 +70,10 @@ src/artifactapi/
|
|||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `GET` | `/api/v1/remote/{remote}/{path}` | Fetch artifact (auto-cache on miss) |
|
| `GET` | `/api/v1/remote/{remote}/{path}` | Fetch artifact (auto-cache on miss) |
|
||||||
|
| `PUT` | `/api/v1/remote/{remote}/{path}` | Upload to local remote |
|
||||||
|
| `HEAD` | `/api/v1/remote/{remote}/{path}` | Check existence (local remotes) |
|
||||||
|
| `DELETE` | `/api/v1/remote/{remote}/{path}` | Delete from local remote |
|
||||||
| `GET` | `/api/v1/virtual/{virtual}/{path}` | Fetch from virtual (merged) repository |
|
| `GET` | `/api/v1/virtual/{virtual}/{path}` | Fetch from virtual (merged) repository |
|
||||||
| `GET` | `/api/v1/local/{local}/{path}` | Download from local repository |
|
|
||||||
| `PUT` | `/api/v1/local/{local}/{path}` | Upload to local repository |
|
|
||||||
| `HEAD` | `/api/v1/local/{local}/{path}` | Check existence (local) |
|
|
||||||
| `DELETE` | `/api/v1/local/{local}/{path}` | Delete from local repository |
|
|
||||||
| `GET` | `/v2/{remote}/{path}` | Docker Registry v2 proxy |
|
| `GET` | `/v2/{remote}/{path}` | Docker Registry v2 proxy |
|
||||||
| `PUT` | `/cache/flush` | Flush cache entries |
|
| `PUT` | `/cache/flush` | Flush cache entries |
|
||||||
| `GET` | `/health` | Health check |
|
| `GET` | `/health` | Health check |
|
||||||
@@ -121,12 +120,12 @@ config_dir: conf.d # or an absolute path
|
|||||||
remotes: {} # optional base remotes
|
remotes: {} # optional base remotes
|
||||||
```
|
```
|
||||||
|
|
||||||
### Configuration structure
|
### remotes.yaml Structure
|
||||||
|
|
||||||
Repositories are declared under three top-level keys matching their type:
|
The top-level key declares the repository type — no `type:` field needed:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
remotes: # proxy (caching) remotes
|
remote:
|
||||||
remote-name:
|
remote-name:
|
||||||
base_url: "https://example.com"
|
base_url: "https://example.com"
|
||||||
package: "generic" # generic, alpine, rpm, docker, pypi, npm, helm
|
package: "generic" # generic, alpine, rpm, docker, pypi, npm, helm
|
||||||
@@ -140,19 +139,16 @@ remotes: # proxy (caching) remotes
|
|||||||
immutable_ttl: 0 # 0 = indefinitely
|
immutable_ttl: 0 # 0 = indefinitely
|
||||||
mutable_ttl: 3600
|
mutable_ttl: 3600
|
||||||
|
|
||||||
virtuals: # virtual (merged-index) repositories
|
virtual:
|
||||||
virtual-name:
|
virtual-name:
|
||||||
package: "helm"
|
package: "helm"
|
||||||
members:
|
members:
|
||||||
- remote-a
|
- remote-name-1
|
||||||
- remote-b
|
- remote-name-2
|
||||||
|
|
||||||
locals: # local upload repositories (no base_url)
|
local:
|
||||||
local-name:
|
local-name:
|
||||||
package: "generic"
|
package: "generic"
|
||||||
cache:
|
|
||||||
immutable_ttl: 0
|
|
||||||
mutable_ttl: 0
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Remote Types
|
## Remote Types
|
||||||
@@ -162,7 +158,7 @@ locals: # local upload repositories (no base_url)
|
|||||||
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"
|
||||||
package: "generic"
|
package: "generic"
|
||||||
@@ -189,7 +185,7 @@ 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"
|
||||||
package: "alpine"
|
package: "alpine"
|
||||||
@@ -205,7 +201,7 @@ remotes:
|
|||||||
### rpm
|
### rpm
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
remotes:
|
remote:
|
||||||
almalinux:
|
almalinux:
|
||||||
base_url: "https://mirror.example.com/almalinux"
|
base_url: "https://mirror.example.com/almalinux"
|
||||||
package: "rpm"
|
package: "rpm"
|
||||||
@@ -222,7 +218,7 @@ remotes:
|
|||||||
### docker
|
### docker
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
remotes:
|
remote:
|
||||||
dockerhub:
|
dockerhub:
|
||||||
base_url: "https://registry-1.docker.io"
|
base_url: "https://registry-1.docker.io"
|
||||||
package: "docker"
|
package: "docker"
|
||||||
@@ -262,7 +258,7 @@ mirrors:
|
|||||||
### pypi
|
### pypi
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
remotes:
|
remote:
|
||||||
pypi:
|
pypi:
|
||||||
base_url: "https://files.pythonhosted.org"
|
base_url: "https://files.pythonhosted.org"
|
||||||
package: "pypi"
|
package: "pypi"
|
||||||
@@ -293,7 +289,7 @@ default = true
|
|||||||
### npm
|
### npm
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
remotes:
|
remote:
|
||||||
npm:
|
npm:
|
||||||
base_url: "https://registry.npmjs.org"
|
base_url: "https://registry.npmjs.org"
|
||||||
package: "npm"
|
package: "npm"
|
||||||
@@ -319,7 +315,7 @@ 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"
|
||||||
package: "helm"
|
package: "helm"
|
||||||
@@ -347,7 +343,7 @@ A virtual repository presents a single unified index built from multiple member
|
|||||||
All members must share the same `package` type as the virtual repo. Currently supported package types: `helm`.
|
All members must share the same `package` type as the virtual repo. Currently supported package types: `helm`.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
remotes:
|
remote:
|
||||||
helm-hashicorp:
|
helm-hashicorp:
|
||||||
base_url: "https://helm.releases.hashicorp.com"
|
base_url: "https://helm.releases.hashicorp.com"
|
||||||
package: "helm"
|
package: "helm"
|
||||||
@@ -366,7 +362,7 @@ remotes:
|
|||||||
immutable_ttl: 0
|
immutable_ttl: 0
|
||||||
mutable_ttl: 3600
|
mutable_ttl: 3600
|
||||||
|
|
||||||
virtuals:
|
virtual:
|
||||||
helm-all:
|
helm-all:
|
||||||
package: "helm"
|
package: "helm"
|
||||||
members:
|
members:
|
||||||
@@ -404,7 +400,7 @@ Chart tarball URLs in the merged `index.yaml` are rewritten to point at the indi
|
|||||||
### local
|
### local
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
locals:
|
local:
|
||||||
local-generic:
|
local-generic:
|
||||||
package: "generic"
|
package: "generic"
|
||||||
description: "Local file repository"
|
description: "Local file repository"
|
||||||
@@ -413,7 +409,99 @@ locals:
|
|||||||
mutable_ttl: 0
|
mutable_ttl: 0
|
||||||
```
|
```
|
||||||
|
|
||||||
No `base_url`. Files are uploaded via `PUT /api/v1/local/{name}/{path}` and downloaded via `GET /api/v1/local/{name}/{path}`.
|
No `base_url`. Files are uploaded via `PUT` and served via `GET`.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
@@ -452,7 +540,7 @@ 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"
|
||||||
package: "pypi"
|
package: "pypi"
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
remotes:
|
||||||
|
alpine:
|
||||||
|
base_url: "https://dl-cdn.alpinelinux.org"
|
||||||
|
type: "remote"
|
||||||
|
package: "alpine"
|
||||||
|
description: "Alpine Linux APK package repository"
|
||||||
|
immutable_patterns:
|
||||||
|
- ".*/x86_64/.*\\.apk$"
|
||||||
|
cache:
|
||||||
|
immutable_ttl: 0
|
||||||
|
mutable_ttl: 7200
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
remotes:
|
||||||
|
github:
|
||||||
|
base_url: "https://github.com"
|
||||||
|
type: "remote"
|
||||||
|
package: "generic"
|
||||||
|
description: "GitHub releases and files"
|
||||||
|
immutable_patterns:
|
||||||
|
- "gruntwork-io/terragrunt/.*terragrunt_linux_amd64.*"
|
||||||
|
- "prometheus/node_exporter/.*/node_exporter-.*\\.linux-amd64\\.tar\\.gz$"
|
||||||
|
cache:
|
||||||
|
immutable_ttl: 0
|
||||||
|
mutable_ttl: 0
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
remotes:
|
||||||
|
pypi:
|
||||||
|
base_url: "https://files.pythonhosted.org"
|
||||||
|
type: "remote"
|
||||||
|
package: "pypi"
|
||||||
|
description: "Python Package Index"
|
||||||
|
check_mutable_updates: true
|
||||||
|
quarantine_new: true
|
||||||
|
quarantine_days: 3
|
||||||
|
immutable_patterns:
|
||||||
|
- "packages/.*\\.whl$"
|
||||||
|
- "packages/.*\\.whl\\.metadata$"
|
||||||
|
- "packages/.*\\.tar\\.gz$"
|
||||||
|
- "packages/.*\\.zip$"
|
||||||
|
cache:
|
||||||
|
immutable_ttl: 0
|
||||||
|
mutable_ttl: 600
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
remotes:
|
remote:
|
||||||
alpine:
|
alpine:
|
||||||
base_url: "https://dl-cdn.alpinelinux.org"
|
base_url: "https://dl-cdn.alpinelinux.org"
|
||||||
package: "alpine"
|
package: "alpine"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
remotes:
|
remote:
|
||||||
github:
|
github:
|
||||||
base_url: "https://github.com"
|
base_url: "https://github.com"
|
||||||
package: "generic"
|
package: "generic"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
remotes:
|
remote:
|
||||||
pypi:
|
pypi:
|
||||||
base_url: "https://files.pythonhosted.org"
|
base_url: "https://files.pythonhosted.org"
|
||||||
package: "pypi"
|
package: "pypi"
|
||||||
|
|||||||
@@ -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,9 +37,11 @@
|
|||||||
#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" package: "generic"
|
base_url: "https://github.com"
|
||||||
|
package: "generic"
|
||||||
description: "GitHub releases and files"
|
description: "GitHub releases and files"
|
||||||
immutable_patterns:
|
immutable_patterns:
|
||||||
- "gruntwork-io/terragrunt/.*terragrunt_linux_amd64.*"
|
- "gruntwork-io/terragrunt/.*terragrunt_linux_amd64.*"
|
||||||
@@ -60,11 +67,12 @@ 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" package: "generic"
|
base_url: "https://github.com"
|
||||||
|
package: "generic"
|
||||||
description: "GitHub repository archive tarballs"
|
description: "GitHub repository archive tarballs"
|
||||||
immutable_patterns:
|
immutable_patterns:
|
||||||
# Tag archives are immutable — a tag never changes
|
# Tag archives are immutable — a tag never changes
|
||||||
@@ -82,16 +90,18 @@ remotes:
|
|||||||
mutable_ttl: 86400 # Branch archives refreshed after 1 day
|
mutable_ttl: 86400 # Branch archives refreshed after 1 day
|
||||||
|
|
||||||
gitea-dl:
|
gitea-dl:
|
||||||
base_url: "https://dl.gitea.com" package: "generic"
|
base_url: "https://dl.gitea.com"
|
||||||
|
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" package: "generic"
|
base_url: "https://releases.hashicorp.com"
|
||||||
|
package: "generic"
|
||||||
description: "HashiCorp product releases"
|
description: "HashiCorp product releases"
|
||||||
immutable_patterns:
|
immutable_patterns:
|
||||||
- "terraform/.*terraform_.*_linux_amd64\\.zip$"
|
- "terraform/.*terraform_.*_linux_amd64\\.zip$"
|
||||||
@@ -106,11 +116,12 @@ 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" package: "alpine"
|
base_url: "https://dl-cdn.alpinelinux.org"
|
||||||
|
package: "alpine"
|
||||||
description: "Alpine Linux APK package repository"
|
description: "Alpine Linux APK package repository"
|
||||||
immutable_patterns:
|
immutable_patterns:
|
||||||
- ".*/x86_64/.*\\.apk$"
|
- ".*/x86_64/.*\\.apk$"
|
||||||
@@ -118,26 +129,25 @@ 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" package: "rpm"
|
base_url: "https://gsl-syd.mm.fcix.net/almalinux"
|
||||||
|
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" package: "rpm"
|
base_url: "http://mirror.aarnet.edu.au/pub/epel"
|
||||||
|
package: "rpm"
|
||||||
description: "EPEL (Extra Packages for Enterprise Linux)"
|
description: "EPEL (Extra Packages for Enterprise Linux)"
|
||||||
immutable_patterns:
|
immutable_patterns:
|
||||||
- "8/Everything/x86_64/.*\\.rpm$"
|
- "8/Everything/x86_64/.*\\.rpm$"
|
||||||
@@ -146,11 +156,12 @@ 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" package: "rpm"
|
base_url: "https://gsl-syd.mm.fcix.net/fedora/linux"
|
||||||
|
package: "rpm"
|
||||||
description: "Fedora Linux RPM package repository"
|
description: "Fedora Linux RPM package repository"
|
||||||
immutable_patterns:
|
immutable_patterns:
|
||||||
- "releases/.*/Everything/x86_64/.*\\.rpm$"
|
- "releases/.*/Everything/x86_64/.*\\.rpm$"
|
||||||
@@ -159,37 +170,32 @@ 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" package: "docker"
|
base_url: "https://ghcr.io"
|
||||||
|
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" package: "docker"
|
base_url: "https://registry-1.docker.io"
|
||||||
|
package: "docker"
|
||||||
description: "Docker Hub registry"
|
description: "Docker Hub registry"
|
||||||
cache:
|
cache:
|
||||||
immutable_ttl: 0
|
immutable_ttl: 0
|
||||||
mutable_ttl: 300
|
mutable_ttl: 300
|
||||||
|
|
||||||
pypi:
|
pypi:
|
||||||
base_url: "https://files.pythonhosted.org" package: "pypi"
|
base_url: "https://files.pythonhosted.org"
|
||||||
|
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:
|
||||||
@@ -203,7 +209,8 @@ remotes:
|
|||||||
mutable_ttl: 600 # Simple index pages refreshed after 10 minutes
|
mutable_ttl: 600 # Simple index pages refreshed after 10 minutes
|
||||||
|
|
||||||
pypi-gitea:
|
pypi-gitea:
|
||||||
base_url: "https://gitea.example.com/api/packages/myorg/pypi" package: "pypi"
|
base_url: "https://gitea.example.com/api/packages/myorg/pypi"
|
||||||
|
package: "pypi"
|
||||||
description: "Private Gitea PyPI registry — simple index and files at the same host"
|
description: "Private Gitea PyPI registry — simple index and files at the same host"
|
||||||
# username: "your-gitea-username"
|
# username: "your-gitea-username"
|
||||||
# password: "your-personal-access-token" # needs package:read scope
|
# password: "your-personal-access-token" # needs package:read scope
|
||||||
@@ -219,7 +226,8 @@ remotes:
|
|||||||
mutable_ttl: 600
|
mutable_ttl: 600
|
||||||
|
|
||||||
npm:
|
npm:
|
||||||
base_url: "https://registry.npmjs.org" package: "npm"
|
base_url: "https://registry.npmjs.org"
|
||||||
|
package: "npm"
|
||||||
description: "npm registry — package metadata with tarball URL rewriting"
|
description: "npm registry — package metadata with tarball URL rewriting"
|
||||||
check_mutable_updates: true
|
check_mutable_updates: true
|
||||||
immutable_patterns:
|
immutable_patterns:
|
||||||
@@ -231,17 +239,19 @@ remotes:
|
|||||||
mutable_ttl: 600 # Package metadata refreshed after 10 minutes
|
mutable_ttl: 600 # Package metadata refreshed after 10 minutes
|
||||||
|
|
||||||
hashicorp-helm:
|
hashicorp-helm:
|
||||||
base_url: "https://helm.releases.hashicorp.com" package: "helm"
|
base_url: "https://helm.releases.hashicorp.com"
|
||||||
|
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:
|
metallb:
|
||||||
base_url: "https://metallb.github.io/metallb" package: "helm"
|
base_url: "https://metallb.github.io/metallb"
|
||||||
|
package: "helm"
|
||||||
description: "MetalLB load balancer Helm charts"
|
description: "MetalLB load balancer Helm charts"
|
||||||
check_mutable_updates: true
|
check_mutable_updates: true
|
||||||
immutable_patterns:
|
immutable_patterns:
|
||||||
@@ -251,7 +261,8 @@ remotes:
|
|||||||
mutable_ttl: 3600
|
mutable_ttl: 3600
|
||||||
|
|
||||||
jetstack:
|
jetstack:
|
||||||
base_url: "https://charts.jetstack.io" package: "helm"
|
base_url: "https://charts.jetstack.io"
|
||||||
|
package: "helm"
|
||||||
description: "Jetstack Helm charts (cert-manager)"
|
description: "Jetstack Helm charts (cert-manager)"
|
||||||
check_mutable_updates: true
|
check_mutable_updates: true
|
||||||
immutable_patterns:
|
immutable_patterns:
|
||||||
@@ -261,7 +272,8 @@ remotes:
|
|||||||
mutable_ttl: 3600
|
mutable_ttl: 3600
|
||||||
|
|
||||||
rancher-stable:
|
rancher-stable:
|
||||||
base_url: "https://releases.rancher.com/server-charts/stable" package: "helm"
|
base_url: "https://releases.rancher.com/server-charts/stable"
|
||||||
|
package: "helm"
|
||||||
description: "Rancher stable Helm charts"
|
description: "Rancher stable Helm charts"
|
||||||
check_mutable_updates: true
|
check_mutable_updates: true
|
||||||
immutable_patterns:
|
immutable_patterns:
|
||||||
@@ -271,7 +283,8 @@ remotes:
|
|||||||
mutable_ttl: 3600
|
mutable_ttl: 3600
|
||||||
|
|
||||||
purelb:
|
purelb:
|
||||||
base_url: "https://gitlab.com/api/v4/projects/20400619/packages/helm/stable" package: "helm"
|
base_url: "https://gitlab.com/api/v4/projects/20400619/packages/helm/stable"
|
||||||
|
package: "helm"
|
||||||
description: "PureLB load balancer Helm charts"
|
description: "PureLB load balancer Helm charts"
|
||||||
check_mutable_updates: true
|
check_mutable_updates: true
|
||||||
immutable_patterns:
|
immutable_patterns:
|
||||||
@@ -281,7 +294,8 @@ remotes:
|
|||||||
mutable_ttl: 3600
|
mutable_ttl: 3600
|
||||||
|
|
||||||
istio:
|
istio:
|
||||||
base_url: "https://istio-release.storage.googleapis.com/charts" package: "helm"
|
base_url: "https://istio-release.storage.googleapis.com/charts"
|
||||||
|
package: "helm"
|
||||||
description: "Istio service mesh Helm charts"
|
description: "Istio service mesh Helm charts"
|
||||||
check_mutable_updates: true
|
check_mutable_updates: true
|
||||||
immutable_patterns:
|
immutable_patterns:
|
||||||
@@ -291,7 +305,8 @@ remotes:
|
|||||||
mutable_ttl: 3600
|
mutable_ttl: 3600
|
||||||
|
|
||||||
cnpg:
|
cnpg:
|
||||||
base_url: "https://cloudnative-pg.github.io/charts" package: "helm"
|
base_url: "https://cloudnative-pg.github.io/charts"
|
||||||
|
package: "helm"
|
||||||
description: "CloudNativePG operator Helm charts"
|
description: "CloudNativePG operator Helm charts"
|
||||||
check_mutable_updates: true
|
check_mutable_updates: true
|
||||||
immutable_patterns:
|
immutable_patterns:
|
||||||
@@ -301,7 +316,8 @@ remotes:
|
|||||||
mutable_ttl: 3600
|
mutable_ttl: 3600
|
||||||
|
|
||||||
ceph-csi:
|
ceph-csi:
|
||||||
base_url: "https://ceph.github.io/csi-charts" package: "helm"
|
base_url: "https://ceph.github.io/csi-charts"
|
||||||
|
package: "helm"
|
||||||
description: "Ceph CSI driver Helm charts"
|
description: "Ceph CSI driver Helm charts"
|
||||||
check_mutable_updates: true
|
check_mutable_updates: true
|
||||||
immutable_patterns:
|
immutable_patterns:
|
||||||
@@ -311,7 +327,8 @@ remotes:
|
|||||||
mutable_ttl: 3600
|
mutable_ttl: 3600
|
||||||
|
|
||||||
external-dns:
|
external-dns:
|
||||||
base_url: "https://kubernetes-sigs.github.io/external-dns/" package: "helm"
|
base_url: "https://kubernetes-sigs.github.io/external-dns/"
|
||||||
|
package: "helm"
|
||||||
description: "ExternalDNS Helm charts"
|
description: "ExternalDNS Helm charts"
|
||||||
check_mutable_updates: true
|
check_mutable_updates: true
|
||||||
immutable_patterns:
|
immutable_patterns:
|
||||||
@@ -321,7 +338,8 @@ remotes:
|
|||||||
mutable_ttl: 3600
|
mutable_ttl: 3600
|
||||||
|
|
||||||
intel-helm:
|
intel-helm:
|
||||||
base_url: "https://intel.github.io/helm-charts/" package: "helm"
|
base_url: "https://intel.github.io/helm-charts/"
|
||||||
|
package: "helm"
|
||||||
description: "Intel Helm charts"
|
description: "Intel Helm charts"
|
||||||
check_mutable_updates: true
|
check_mutable_updates: true
|
||||||
immutable_patterns:
|
immutable_patterns:
|
||||||
@@ -331,7 +349,8 @@ remotes:
|
|||||||
mutable_ttl: 3600
|
mutable_ttl: 3600
|
||||||
|
|
||||||
elastic:
|
elastic:
|
||||||
base_url: "https://helm.elastic.co" package: "helm"
|
base_url: "https://helm.elastic.co"
|
||||||
|
package: "helm"
|
||||||
description: "Elastic stack Helm charts"
|
description: "Elastic stack Helm charts"
|
||||||
check_mutable_updates: true
|
check_mutable_updates: true
|
||||||
immutable_patterns:
|
immutable_patterns:
|
||||||
@@ -341,7 +360,8 @@ remotes:
|
|||||||
mutable_ttl: 3600
|
mutable_ttl: 3600
|
||||||
|
|
||||||
k8up-io:
|
k8up-io:
|
||||||
base_url: "https://k8up-io.github.io/k8up" package: "helm"
|
base_url: "https://k8up-io.github.io/k8up"
|
||||||
|
package: "helm"
|
||||||
description: "K8up backup operator Helm charts"
|
description: "K8up backup operator Helm charts"
|
||||||
check_mutable_updates: true
|
check_mutable_updates: true
|
||||||
immutable_patterns:
|
immutable_patterns:
|
||||||
@@ -351,7 +371,8 @@ remotes:
|
|||||||
mutable_ttl: 3600
|
mutable_ttl: 3600
|
||||||
|
|
||||||
victoriametrics:
|
victoriametrics:
|
||||||
base_url: "https://victoriametrics.github.io/helm-charts/" package: "helm"
|
base_url: "https://victoriametrics.github.io/helm-charts/"
|
||||||
|
package: "helm"
|
||||||
description: "VictoriaMetrics observability Helm charts"
|
description: "VictoriaMetrics observability Helm charts"
|
||||||
check_mutable_updates: true
|
check_mutable_updates: true
|
||||||
immutable_patterns:
|
immutable_patterns:
|
||||||
@@ -361,7 +382,8 @@ remotes:
|
|||||||
mutable_ttl: 3600
|
mutable_ttl: 3600
|
||||||
|
|
||||||
grafana:
|
grafana:
|
||||||
base_url: "https://grafana.github.io/helm-charts" package: "helm"
|
base_url: "https://grafana.github.io/helm-charts"
|
||||||
|
package: "helm"
|
||||||
description: "Grafana observability Helm charts"
|
description: "Grafana observability Helm charts"
|
||||||
check_mutable_updates: true
|
check_mutable_updates: true
|
||||||
immutable_patterns:
|
immutable_patterns:
|
||||||
@@ -371,7 +393,8 @@ remotes:
|
|||||||
mutable_ttl: 3600
|
mutable_ttl: 3600
|
||||||
|
|
||||||
helm-openldap:
|
helm-openldap:
|
||||||
base_url: "https://jp-gouin.github.io/helm-openldap/" package: "helm"
|
base_url: "https://jp-gouin.github.io/helm-openldap/"
|
||||||
|
package: "helm"
|
||||||
description: "OpenLDAP Helm charts"
|
description: "OpenLDAP Helm charts"
|
||||||
check_mutable_updates: true
|
check_mutable_updates: true
|
||||||
immutable_patterns:
|
immutable_patterns:
|
||||||
@@ -381,7 +404,8 @@ remotes:
|
|||||||
mutable_ttl: 3600
|
mutable_ttl: 3600
|
||||||
|
|
||||||
woodpecker:
|
woodpecker:
|
||||||
base_url: "https://woodpecker-ci.org/" package: "helm"
|
base_url: "https://woodpecker-ci.org/"
|
||||||
|
package: "helm"
|
||||||
description: "Woodpecker CI Helm charts"
|
description: "Woodpecker CI Helm charts"
|
||||||
check_mutable_updates: true
|
check_mutable_updates: true
|
||||||
immutable_patterns:
|
immutable_patterns:
|
||||||
@@ -391,7 +415,8 @@ remotes:
|
|||||||
mutable_ttl: 3600
|
mutable_ttl: 3600
|
||||||
|
|
||||||
stakater:
|
stakater:
|
||||||
base_url: "https://stakater.github.io/stakater-charts" package: "helm"
|
base_url: "https://stakater.github.io/stakater-charts"
|
||||||
|
package: "helm"
|
||||||
description: "Stakater Helm charts"
|
description: "Stakater Helm charts"
|
||||||
check_mutable_updates: true
|
check_mutable_updates: true
|
||||||
immutable_patterns:
|
immutable_patterns:
|
||||||
@@ -401,7 +426,8 @@ remotes:
|
|||||||
mutable_ttl: 3600
|
mutable_ttl: 3600
|
||||||
|
|
||||||
jfrog:
|
jfrog:
|
||||||
base_url: "https://charts.jfrog.io/" package: "helm"
|
base_url: "https://charts.jfrog.io/"
|
||||||
|
package: "helm"
|
||||||
description: "JFrog Helm charts"
|
description: "JFrog Helm charts"
|
||||||
check_mutable_updates: true
|
check_mutable_updates: true
|
||||||
immutable_patterns:
|
immutable_patterns:
|
||||||
@@ -411,7 +437,8 @@ remotes:
|
|||||||
mutable_ttl: 3600
|
mutable_ttl: 3600
|
||||||
|
|
||||||
openvox:
|
openvox:
|
||||||
base_url: "https://openvoxproject.github.io/openvox-helm-chart" package: "helm"
|
base_url: "https://openvoxproject.github.io/openvox-helm-chart"
|
||||||
|
package: "helm"
|
||||||
description: "OpenVox Helm charts"
|
description: "OpenVox Helm charts"
|
||||||
check_mutable_updates: true
|
check_mutable_updates: true
|
||||||
immutable_patterns:
|
immutable_patterns:
|
||||||
@@ -420,8 +447,7 @@ remotes:
|
|||||||
immutable_ttl: 0
|
immutable_ttl: 0
|
||||||
mutable_ttl: 3600
|
mutable_ttl: 3600
|
||||||
|
|
||||||
|
virtual:
|
||||||
virtuals:
|
|
||||||
helm-all:
|
helm-all:
|
||||||
package: "helm"
|
package: "helm"
|
||||||
description: "Virtual repository merging all helm remotes — member order is priority order for duplicate chart+version"
|
description: "Virtual repository merging all helm remotes — member order is priority order for duplicate chart+version"
|
||||||
@@ -446,10 +472,10 @@ virtuals:
|
|||||||
- jfrog
|
- jfrog
|
||||||
- openvox
|
- openvox
|
||||||
|
|
||||||
locals:
|
local:
|
||||||
local-generic:
|
local-generic:
|
||||||
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
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
|
|
||||||
from fastapi import HTTPException, Response, UploadFile
|
from fastapi import HTTPException, Response, UploadFile
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
@@ -8,23 +7,12 @@ from fastapi.responses import JSONResponse
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def download(remote_name: str, path: str, storage, database, config) -> Response:
|
|
||||||
if not config.get_local_config(remote_name):
|
|
||||||
raise HTTPException(status_code=404, detail=f"Local repository '{remote_name}' not configured")
|
|
||||||
metadata = database.get_local_file_metadata(remote_name, path)
|
|
||||||
if not metadata:
|
|
||||||
raise HTTPException(status_code=404, detail="File not found")
|
|
||||||
content = storage.download_object(metadata["s3_key"])
|
|
||||||
return Response(
|
|
||||||
content=content,
|
|
||||||
media_type=metadata.get("content_type", "application/octet-stream"),
|
|
||||||
headers={"Content-Disposition": f"attachment; filename={os.path.basename(path)}"},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def upload(remote_name: str, path: str, file: UploadFile, storage, database, config) -> JSONResponse:
|
async def upload(remote_name: str, path: str, file: UploadFile, storage, database, config) -> JSONResponse:
|
||||||
if not config.get_local_config(remote_name):
|
remote_config = config.get_remote_config(remote_name)
|
||||||
raise HTTPException(status_code=404, detail=f"Local repository '{remote_name}' not configured")
|
if not remote_config:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Remote '{remote_name}' not configured")
|
||||||
|
if remote_config.get("type") != "local":
|
||||||
|
raise HTTPException(status_code=400, detail="Upload only supported for local repositories")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
content = await file.read()
|
content = await file.read()
|
||||||
@@ -71,8 +59,12 @@ async def upload(remote_name: str, path: str, file: UploadFile, storage, databas
|
|||||||
|
|
||||||
|
|
||||||
def check_exists(remote_name: str, path: str, database, config) -> Response:
|
def check_exists(remote_name: str, path: str, database, config) -> Response:
|
||||||
if not config.get_local_config(remote_name):
|
remote_config = config.get_remote_config(remote_name)
|
||||||
raise HTTPException(status_code=404, detail=f"Local repository '{remote_name}' not configured")
|
if not remote_config:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Remote '{remote_name}' not configured")
|
||||||
|
|
||||||
|
if remote_config.get("type") != "local":
|
||||||
|
raise HTTPException(status_code=405, detail="HEAD method only supported for local repositories")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
metadata = database.get_local_file_metadata(remote_name, path)
|
metadata = database.get_local_file_metadata(remote_name, path)
|
||||||
@@ -95,8 +87,11 @@ def check_exists(remote_name: str, path: str, database, config) -> Response:
|
|||||||
|
|
||||||
|
|
||||||
def delete(remote_name: str, path: str, storage, database, config) -> JSONResponse:
|
def delete(remote_name: str, path: str, storage, database, config) -> JSONResponse:
|
||||||
if not config.get_local_config(remote_name):
|
remote_config = config.get_remote_config(remote_name)
|
||||||
raise HTTPException(status_code=404, detail=f"Local repository '{remote_name}' not configured")
|
if not remote_config:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Remote '{remote_name}' not configured")
|
||||||
|
if remote_config.get("type") != "local":
|
||||||
|
raise HTTPException(status_code=400, detail="Delete only supported for local repositories")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
s3_key = database.delete_local_file(remote_name, path)
|
s3_key = database.delete_local_file(remote_name, path)
|
||||||
|
|||||||
@@ -218,6 +218,19 @@ async def handle(request: Request, remote_name: str, path: str, storage, cache,
|
|||||||
if not remote_config:
|
if not remote_config:
|
||||||
raise HTTPException(status_code=404, detail=f"Remote '{remote_name}' not configured")
|
raise HTTPException(status_code=404, detail=f"Remote '{remote_name}' not configured")
|
||||||
|
|
||||||
|
if remote_config.get("type") == "local":
|
||||||
|
metadata = database.get_local_file_metadata(remote_name, path)
|
||||||
|
if not metadata:
|
||||||
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
content = storage.download_object(metadata["s3_key"])
|
||||||
|
if content is None:
|
||||||
|
raise HTTPException(status_code=500, detail="File not accessible")
|
||||||
|
return Response(
|
||||||
|
content=content,
|
||||||
|
media_type=metadata.get("content_type", "application/octet-stream"),
|
||||||
|
headers={"Content-Disposition": f"attachment; filename={os.path.basename(path)}"},
|
||||||
|
)
|
||||||
|
|
||||||
path_parts = path.split("/")
|
path_parts = path.split("/")
|
||||||
if len(path_parts) >= 2:
|
if len(path_parts) >= 2:
|
||||||
repo_path = f"{path_parts[0]}/{path_parts[1]}"
|
repo_path = f"{path_parts[0]}/{path_parts[1]}"
|
||||||
|
|||||||
@@ -147,9 +147,11 @@ _HANDLERS: dict[str, _VirtualHandler] = {
|
|||||||
|
|
||||||
|
|
||||||
async def handle(request: Request, virtual_name: str, path: str, storage, cache, config) -> Response:
|
async def handle(request: Request, virtual_name: str, path: str, storage, cache, config) -> Response:
|
||||||
virtual_cfg = config.get_virtual_config(virtual_name)
|
virtual_cfg = config.get_remote_config(virtual_name)
|
||||||
if not virtual_cfg:
|
if not virtual_cfg:
|
||||||
raise HTTPException(status_code=404, detail=f"Virtual repository '{virtual_name}' not configured")
|
raise HTTPException(status_code=404, detail=f"Virtual repository '{virtual_name}' not configured")
|
||||||
|
if virtual_cfg.get("type") != "virtual":
|
||||||
|
raise HTTPException(status_code=400, detail=f"'{virtual_name}' is not a virtual repository")
|
||||||
|
|
||||||
package = virtual_cfg.get("package")
|
package = virtual_cfg.get("package")
|
||||||
handler = _HANDLERS.get(package)
|
handler = _HANDLERS.get(package)
|
||||||
|
|||||||
+23
-14
@@ -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 {}
|
||||||
|
|
||||||
@@ -50,8 +67,8 @@ class ConfigManager:
|
|||||||
def _merge(base: dict, overlay: dict) -> dict:
|
def _merge(base: dict, overlay: dict) -> dict:
|
||||||
result = {**base}
|
result = {**base}
|
||||||
for key, value in overlay.items():
|
for key, value in overlay.items():
|
||||||
if key in ("remotes", "virtuals", "locals") and isinstance(base.get(key), dict) and isinstance(value, dict):
|
if key == "remotes" and isinstance(base.get("remotes"), dict) and isinstance(value, dict):
|
||||||
result[key] = {**base.get(key, {}), **value}
|
result["remotes"] = {**base.get("remotes", {}), **value}
|
||||||
else:
|
else:
|
||||||
result[key] = value
|
result[key] = value
|
||||||
return result
|
return result
|
||||||
@@ -67,11 +84,11 @@ class ConfigManager:
|
|||||||
self._config_dir = None
|
self._config_dir = None
|
||||||
|
|
||||||
if os.path.isdir(self.config_path):
|
if os.path.isdir(self.config_path):
|
||||||
return self._load_from_dir(self.config_path) or {"remotes": {}, "virtuals": {}, "locals": {}}
|
return self._load_from_dir(self.config_path) or {"remotes": {}}
|
||||||
|
|
||||||
config = self._load_single_file(self.config_path)
|
config = self._load_single_file(self.config_path)
|
||||||
if not config:
|
if not config:
|
||||||
return {"remotes": {}, "virtuals": {}, "locals": {}}
|
return {"remotes": {}}
|
||||||
|
|
||||||
config_dir = config.pop("config_dir", None)
|
config_dir = config.pop("config_dir", None)
|
||||||
if config_dir:
|
if config_dir:
|
||||||
@@ -119,14 +136,6 @@ class ConfigManager:
|
|||||||
self._check_reload()
|
self._check_reload()
|
||||||
return self.config.get("remotes", {}).get(remote_name)
|
return self.config.get("remotes", {}).get(remote_name)
|
||||||
|
|
||||||
def get_virtual_config(self, virtual_name: str) -> dict | None:
|
|
||||||
self._check_reload()
|
|
||||||
return self.config.get("virtuals", {}).get(virtual_name)
|
|
||||||
|
|
||||||
def get_local_config(self, local_name: str) -> dict | None:
|
|
||||||
self._check_reload()
|
|
||||||
return self.config.get("locals", {}).get(local_name)
|
|
||||||
|
|
||||||
def get_immutable_patterns(self, remote_name: str, repo_path: str = "") -> list[str]:
|
def get_immutable_patterns(self, remote_name: str, repo_path: str = "") -> list[str]:
|
||||||
remote_config = self.get_remote_config(remote_name)
|
remote_config = self.get_remote_config(remote_name)
|
||||||
if not remote_config:
|
if not remote_config:
|
||||||
|
|||||||
+10
-21
@@ -49,13 +49,7 @@ class ArtifactRequest(BaseModel):
|
|||||||
@app.get("/")
|
@app.get("/")
|
||||||
def read_root():
|
def read_root():
|
||||||
config._check_reload()
|
config._check_reload()
|
||||||
return {
|
return {"message": "Artifact Storage API", "version": app.version, "remotes": list(config.config.get("remotes", {}).keys())}
|
||||||
"message": "Artifact Storage API",
|
|
||||||
"version": app.version,
|
|
||||||
"remotes": list(config.config.get("remotes", {}).keys()),
|
|
||||||
"virtuals": list(config.config.get("virtuals", {}).keys()),
|
|
||||||
"locals": list(config.config.get("locals", {}).keys()),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
@@ -105,24 +99,19 @@ async def get_artifact(request: Request, remote_name: str, path: str):
|
|||||||
return await proxy.handle(request, remote_name, path, storage, cache, config, database, metrics)
|
return await proxy.handle(request, remote_name, path, storage, cache, config, database, metrics)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/local/{local_name}/{path:path}")
|
@app.put("/api/v1/remote/{remote_name}/{path:path}")
|
||||||
def get_local_artifact(local_name: str, path: str):
|
async def upload_file(remote_name: str, path: str, file: UploadFile = File(...)):
|
||||||
return local.download(local_name, path, storage, database, config)
|
return await local.upload(remote_name, path, file, storage, database, config)
|
||||||
|
|
||||||
|
|
||||||
@app.put("/api/v1/local/{local_name}/{path:path}")
|
@app.head("/api/v1/remote/{remote_name}/{path:path}")
|
||||||
async def upload_local_file(local_name: str, path: str, file: UploadFile = File(...)):
|
def check_file_exists(remote_name: str, path: str):
|
||||||
return await local.upload(local_name, path, file, storage, database, config)
|
return local.check_exists(remote_name, path, database, config)
|
||||||
|
|
||||||
|
|
||||||
@app.head("/api/v1/local/{local_name}/{path:path}")
|
@app.delete("/api/v1/remote/{remote_name}/{path:path}")
|
||||||
def check_local_file_exists(local_name: str, path: str):
|
def delete_file(remote_name: str, path: str):
|
||||||
return local.check_exists(local_name, path, database, config)
|
return local.delete(remote_name, path, storage, database, config)
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/api/v1/local/{local_name}/{path:path}")
|
|
||||||
def delete_local_file(local_name: str, path: str):
|
|
||||||
return local.delete(local_name, path, storage, database, config)
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/v1/artifacts/cache")
|
@app.post("/api/v1/artifacts/cache")
|
||||||
|
|||||||
@@ -87,10 +87,9 @@ class MetricsManager:
|
|||||||
# Get from database if available
|
# Get from database if available
|
||||||
db_sizes = self.database_manager.get_storage_by_remote()
|
db_sizes = self.database_manager.get_storage_by_remote()
|
||||||
if db_sizes:
|
if db_sizes:
|
||||||
# Initialize all configured remotes and locals to 0
|
# Initialize all configured remotes to 0
|
||||||
remote_sizes = {}
|
remote_sizes = {}
|
||||||
all_names = list(config_manager.config.get("remotes", {}).keys()) + list(config_manager.config.get("locals", {}).keys())
|
for remote in config_manager.config.get("remotes", {}).keys():
|
||||||
for remote in all_names:
|
|
||||||
remote_sizes[remote] = db_sizes.get(remote, 0)
|
remote_sizes[remote] = db_sizes.get(remote, 0)
|
||||||
|
|
||||||
# Update Prometheus gauges
|
# Update Prometheus gauges
|
||||||
@@ -102,10 +101,10 @@ class MetricsManager:
|
|||||||
# Fallback to S3 scanning if database not available
|
# Fallback to S3 scanning if database not available
|
||||||
try:
|
try:
|
||||||
remote_sizes = {}
|
remote_sizes = {}
|
||||||
all_names = list(config_manager.config.get("remotes", {}).keys()) + list(config_manager.config.get("locals", {}).keys())
|
remotes = config_manager.config.get("remotes", {}).keys()
|
||||||
|
|
||||||
# Initialize all remotes and locals to 0
|
# Initialize all remotes to 0
|
||||||
for remote in all_names:
|
for remote in remotes:
|
||||||
remote_sizes[remote] = 0
|
remote_sizes[remote] = 0
|
||||||
|
|
||||||
paginator = storage.client.get_paginator("list_objects_v2")
|
paginator = storage.client.get_paginator("list_objects_v2")
|
||||||
@@ -175,13 +174,8 @@ class MetricsManager:
|
|||||||
metrics["requests"]["cache_hit_ratio"] = cache_hits / total_requests if total_requests > 0 else 0.0
|
metrics["requests"]["cache_hit_ratio"] = cache_hits / total_requests if total_requests > 0 else 0.0
|
||||||
metrics["bandwidth"]["saved_bytes"] = bandwidth_saved
|
metrics["bandwidth"]["saved_bytes"] = bandwidth_saved
|
||||||
|
|
||||||
# Get per-repo metrics
|
# Get per-remote metrics
|
||||||
all_repos = {
|
for remote in config_manager.config.get("remotes", {}).keys():
|
||||||
**config_manager.config.get("remotes", {}),
|
|
||||||
**config_manager.config.get("virtuals", {}),
|
|
||||||
**config_manager.config.get("locals", {}),
|
|
||||||
}
|
|
||||||
for remote in all_repos.keys():
|
|
||||||
remote_cache_hits = int(self.redis_client.client.get(f"metrics:cache_hits:{remote}") or 0)
|
remote_cache_hits = int(self.redis_client.client.get(f"metrics:cache_hits:{remote}") or 0)
|
||||||
remote_cache_misses = int(self.redis_client.client.get(f"metrics:cache_misses:{remote}") or 0)
|
remote_cache_misses = int(self.redis_client.client.get(f"metrics:cache_misses:{remote}") or 0)
|
||||||
remote_total = remote_cache_hits + remote_cache_misses
|
remote_total = remote_cache_hits + remote_cache_misses
|
||||||
|
|||||||
+22
-9
@@ -20,48 +20,61 @@ TEST_REMOTES = {
|
|||||||
"remotes": {
|
"remotes": {
|
||||||
"alpine-test": {
|
"alpine-test": {
|
||||||
"base_url": "https://dl-cdn.alpinelinux.org",
|
"base_url": "https://dl-cdn.alpinelinux.org",
|
||||||
|
"type": "remote",
|
||||||
"package": "alpine",
|
"package": "alpine",
|
||||||
"immutable_patterns": [".*/x86_64/.*\\.apk$"],
|
"immutable_patterns": [".*/x86_64/.*\\.apk$"],
|
||||||
"cache": {"immutable_ttl": 0, "mutable_ttl": 3600},
|
"cache": {"immutable_ttl": 0, "mutable_ttl": 3600},
|
||||||
},
|
},
|
||||||
"rpm-test": {
|
"rpm-test": {
|
||||||
"base_url": "https://example.com/rpm",
|
"base_url": "https://example.com/rpm",
|
||||||
|
"type": "remote",
|
||||||
"package": "rpm",
|
"package": "rpm",
|
||||||
"immutable_patterns": [".*/x86_64/.*\\.rpm$", ".*/repodata/.*$"],
|
"immutable_patterns": [".*/x86_64/.*\\.rpm$", ".*/repodata/.*$"],
|
||||||
"cache": {"immutable_ttl": 0, "mutable_ttl": 3600},
|
"cache": {"immutable_ttl": 0, "mutable_ttl": 3600},
|
||||||
},
|
},
|
||||||
"docker-test": {
|
"docker-test": {
|
||||||
"base_url": "https://registry.example.com",
|
"base_url": "https://registry.example.com",
|
||||||
|
"type": "remote",
|
||||||
"package": "docker",
|
"package": "docker",
|
||||||
"cache": {"immutable_ttl": 0, "mutable_ttl": 300},
|
"cache": {"immutable_ttl": 0, "mutable_ttl": 300},
|
||||||
},
|
},
|
||||||
"docker-restricted": {
|
"docker-restricted": {
|
||||||
"base_url": "https://registry.example.com",
|
"base_url": "https://registry.example.com",
|
||||||
|
"type": "remote",
|
||||||
"package": "docker",
|
"package": "docker",
|
||||||
"immutable_patterns": ["^library/nginx"],
|
"immutable_patterns": ["^library/nginx"],
|
||||||
"cache": {"immutable_ttl": 0, "mutable_ttl": 300},
|
"cache": {"immutable_ttl": 0, "mutable_ttl": 300},
|
||||||
},
|
},
|
||||||
"generic-test": {
|
"generic-test": {
|
||||||
"base_url": "https://releases.example.com",
|
"base_url": "https://releases.example.com",
|
||||||
|
"type": "remote",
|
||||||
"package": "generic",
|
"package": "generic",
|
||||||
"immutable_patterns": [".*\\.tar\\.gz$"],
|
"immutable_patterns": [".*\\.tar\\.gz$"],
|
||||||
"cache": {"immutable_ttl": 0, "mutable_ttl": 0},
|
"cache": {"immutable_ttl": 0, "mutable_ttl": 0},
|
||||||
},
|
},
|
||||||
"custom-index-test": {
|
"custom-index-test": {
|
||||||
"base_url": "https://example.com",
|
"base_url": "https://example.com",
|
||||||
|
"type": "remote",
|
||||||
"package": "generic",
|
"package": "generic",
|
||||||
"mutable_patterns": ["metadata\\.json$"],
|
"mutable_patterns": ["metadata\\.json$"],
|
||||||
"cache": {"immutable_ttl": 0, "mutable_ttl": 600},
|
"cache": {"immutable_ttl": 0, "mutable_ttl": 600},
|
||||||
},
|
},
|
||||||
"check-mutable-test": {
|
"check-mutable-test": {
|
||||||
"base_url": "https://example.com",
|
"base_url": "https://example.com",
|
||||||
|
"type": "remote",
|
||||||
"package": "generic",
|
"package": "generic",
|
||||||
"mutable_patterns": ["metadata\\.json$"],
|
"mutable_patterns": ["metadata\\.json$"],
|
||||||
"check_mutable_updates": True,
|
"check_mutable_updates": True,
|
||||||
"cache": {"immutable_ttl": 0, "mutable_ttl": 600},
|
"cache": {"immutable_ttl": 0, "mutable_ttl": 600},
|
||||||
},
|
},
|
||||||
|
"local-test": {
|
||||||
|
"type": "local",
|
||||||
|
"package": "generic",
|
||||||
|
"cache": {"immutable_ttl": 0, "mutable_ttl": 0},
|
||||||
|
},
|
||||||
"pypi-test": {
|
"pypi-test": {
|
||||||
"base_url": "https://files.pythonhosted.org",
|
"base_url": "https://files.pythonhosted.org",
|
||||||
|
"type": "remote",
|
||||||
"package": "pypi",
|
"package": "pypi",
|
||||||
"immutable_patterns": [
|
"immutable_patterns": [
|
||||||
r"packages/.*\.whl$",
|
r"packages/.*\.whl$",
|
||||||
@@ -72,6 +85,7 @@ TEST_REMOTES = {
|
|||||||
},
|
},
|
||||||
"npm-test": {
|
"npm-test": {
|
||||||
"base_url": "https://registry.npmjs.org",
|
"base_url": "https://registry.npmjs.org",
|
||||||
|
"type": "remote",
|
||||||
"package": "npm",
|
"package": "npm",
|
||||||
"immutable_patterns": [r"\.tgz$"],
|
"immutable_patterns": [r"\.tgz$"],
|
||||||
"mutable_patterns": [r"^(?!.*\.tgz$).*"],
|
"mutable_patterns": [r"^(?!.*\.tgz$).*"],
|
||||||
@@ -79,12 +93,14 @@ TEST_REMOTES = {
|
|||||||
},
|
},
|
||||||
"helm-test": {
|
"helm-test": {
|
||||||
"base_url": "https://helm.releases.hashicorp.com",
|
"base_url": "https://helm.releases.hashicorp.com",
|
||||||
|
"type": "remote",
|
||||||
"package": "helm",
|
"package": "helm",
|
||||||
"immutable_patterns": [r"\.tgz$"],
|
"immutable_patterns": [r"\.tgz$"],
|
||||||
"cache": {"immutable_ttl": 0, "mutable_ttl": 3600},
|
"cache": {"immutable_ttl": 0, "mutable_ttl": 3600},
|
||||||
},
|
},
|
||||||
"quarantine-test": {
|
"quarantine-test": {
|
||||||
"base_url": "https://releases.example.com",
|
"base_url": "https://releases.example.com",
|
||||||
|
"type": "remote",
|
||||||
"package": "generic",
|
"package": "generic",
|
||||||
"immutable_patterns": [r".*\.tar\.gz$"],
|
"immutable_patterns": [r".*\.tar\.gz$"],
|
||||||
"quarantine_new": True,
|
"quarantine_new": True,
|
||||||
@@ -93,6 +109,7 @@ TEST_REMOTES = {
|
|||||||
},
|
},
|
||||||
"quarantine-disabled": {
|
"quarantine-disabled": {
|
||||||
"base_url": "https://releases.example.com",
|
"base_url": "https://releases.example.com",
|
||||||
|
"type": "remote",
|
||||||
"package": "generic",
|
"package": "generic",
|
||||||
"immutable_patterns": [r".*\.tar\.gz$"],
|
"immutable_patterns": [r".*\.tar\.gz$"],
|
||||||
"quarantine_new": False,
|
"quarantine_new": False,
|
||||||
@@ -101,31 +118,27 @@ TEST_REMOTES = {
|
|||||||
},
|
},
|
||||||
"helm-member-2": {
|
"helm-member-2": {
|
||||||
"base_url": "https://charts.example.com",
|
"base_url": "https://charts.example.com",
|
||||||
|
"type": "remote",
|
||||||
"package": "helm",
|
"package": "helm",
|
||||||
"immutable_patterns": [r"\.tgz$"],
|
"immutable_patterns": [r"\.tgz$"],
|
||||||
"cache": {"immutable_ttl": 0, "mutable_ttl": 1800},
|
"cache": {"immutable_ttl": 0, "mutable_ttl": 1800},
|
||||||
},
|
},
|
||||||
},
|
|
||||||
"locals": {
|
|
||||||
"local-test": {
|
|
||||||
"package": "generic",
|
|
||||||
"cache": {"immutable_ttl": 0, "mutable_ttl": 0},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"virtuals": {
|
|
||||||
"helm-virtual-test": {
|
"helm-virtual-test": {
|
||||||
|
"type": "virtual",
|
||||||
"package": "helm",
|
"package": "helm",
|
||||||
"members": ["helm-test", "helm-member-2"],
|
"members": ["helm-test", "helm-member-2"],
|
||||||
},
|
},
|
||||||
"unsupported-virtual-test": {
|
"unsupported-virtual-test": {
|
||||||
|
"type": "virtual",
|
||||||
"package": "rpm",
|
"package": "rpm",
|
||||||
"members": ["rpm-test"],
|
"members": ["rpm-test"],
|
||||||
},
|
},
|
||||||
"empty-virtual-test": {
|
"empty-virtual-test": {
|
||||||
|
"type": "virtual",
|
||||||
"package": "helm",
|
"package": "helm",
|
||||||
"members": [],
|
"members": [],
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
+98
-40
@@ -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
|
||||||
@@ -64,7 +64,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": [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$"],
|
||||||
@@ -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$).*"],
|
||||||
@@ -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$"],
|
||||||
@@ -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$"],
|
||||||
@@ -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},
|
||||||
@@ -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": {"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": {"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": {"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
|
||||||
@@ -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,
|
||||||
@@ -431,36 +415,36 @@ def _remote(base_url: str = "https://x.com") -> dict:
|
|||||||
|
|
||||||
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"
|
||||||
|
|
||||||
def test_empty_directory_returns_empty_remotes(self, tmp_path):
|
def test_empty_directory_returns_empty_remotes(self, tmp_path):
|
||||||
cfg = ConfigManager(str(tmp_path))
|
cfg = ConfigManager(str(tmp_path))
|
||||||
assert cfg.config == {"remotes": {}, "virtuals": {}, "locals": {}}
|
assert cfg.config == {"remotes": {}}
|
||||||
|
|
||||||
def test_ignores_non_yaml_files(self, tmp_path):
|
def test_ignores_non_yaml_files(self, tmp_path):
|
||||||
(tmp_path / "notes.txt").write_text("not yaml")
|
(tmp_path / "notes.txt").write_text("not yaml")
|
||||||
(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"]
|
||||||
|
|||||||
+26
-11
@@ -523,53 +523,68 @@ class TestGenericArtifactRoute:
|
|||||||
deps["database"].get_local_file_metadata.return_value = None
|
deps["database"].get_local_file_metadata.return_value = None
|
||||||
deps["database"].available = True
|
deps["database"].available = True
|
||||||
|
|
||||||
response = client.get("/api/v1/local/local-test/path/to/nonexistent.bin")
|
response = client.get("/api/v1/remote/local-test/path/to/nonexistent.bin")
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Upload route PUT /api/v1/local/{local}/{path}
|
# Upload route PUT /api/v1/remote/{remote}/{path}
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TestUploadRoute:
|
class TestUploadRoute:
|
||||||
def test_unknown_local_returns_404(self, client, patched_deps):
|
def test_unknown_remote_returns_404(self, client, patched_deps):
|
||||||
response = client.put(
|
response = client.put(
|
||||||
"/api/v1/local/nonexistent/path/to/file.tar.gz",
|
"/api/v1/remote/nonexistent/path/to/file.tar.gz",
|
||||||
files={"file": ("file.tar.gz", b"content", "application/octet-stream")},
|
files={"file": ("file.tar.gz", b"content", "application/octet-stream")},
|
||||||
)
|
)
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
def test_non_local_remote_returns_400(self, client, patched_deps):
|
||||||
|
response = client.put(
|
||||||
|
"/api/v1/remote/generic-test/path/to/file.tar.gz",
|
||||||
|
files={"file": ("file.tar.gz", b"content", "application/octet-stream")},
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# HEAD route HEAD /api/v1/local/{local}/{path}
|
# HEAD route HEAD /api/v1/remote/{remote}/{path}
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TestHeadRoute:
|
class TestHeadRoute:
|
||||||
|
def test_non_local_remote_returns_405(self, client, patched_deps):
|
||||||
|
response = client.head("/api/v1/remote/generic-test/path/to/file.tar.gz")
|
||||||
|
assert response.status_code == 405
|
||||||
|
|
||||||
def test_local_repo_file_not_found_returns_404(self, client, patched_deps):
|
def test_local_repo_file_not_found_returns_404(self, client, patched_deps):
|
||||||
deps = patched_deps
|
deps = patched_deps
|
||||||
deps["database"].get_local_file_metadata.return_value = None
|
deps["database"].get_local_file_metadata.return_value = None
|
||||||
deps["database"].available = True
|
deps["database"].available = True
|
||||||
|
|
||||||
response = client.head("/api/v1/local/local-test/path/to/nonexistent.bin")
|
response = client.head("/api/v1/remote/local-test/path/to/nonexistent.bin")
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
def test_unknown_local_returns_404(self, client, patched_deps):
|
def test_unknown_remote_returns_404(self, client, patched_deps):
|
||||||
response = client.head("/api/v1/local/nonexistent/path/to/file.bin")
|
response = client.head("/api/v1/remote/nonexistent/path/to/file.bin")
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# DELETE route DELETE /api/v1/local/{local}/{path}
|
# DELETE route DELETE /api/v1/remote/{remote}/{path}
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TestDeleteRoute:
|
class TestDeleteRoute:
|
||||||
def test_unknown_local_returns_404(self, client, patched_deps):
|
def test_unknown_remote_returns_404(self, client, patched_deps):
|
||||||
response = client.delete("/api/v1/local/nonexistent/path/to/file.tar.gz")
|
response = client.delete("/api/v1/remote/nonexistent/path/to/file.tar.gz")
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
def test_non_local_remote_returns_400(self, client, patched_deps):
|
||||||
|
response = client.delete("/api/v1/remote/generic-test/path/to/file.tar.gz")
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Cache flush PUT /cache/flush
|
# Cache flush PUT /cache/flush
|
||||||
|
|||||||
@@ -430,10 +430,10 @@ class TestVirtualRoute:
|
|||||||
response = client.get("/api/v1/virtual/no-such-virtual/index.yaml")
|
response = client.get("/api/v1/virtual/no-such-virtual/index.yaml")
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
def test_non_virtual_name_returns_404(self, client, patched_virtual_deps):
|
def test_non_virtual_type_returns_400(self, client, patched_virtual_deps):
|
||||||
# helm-test is in remotes, not virtuals
|
# helm-test is type "remote", not "virtual"
|
||||||
response = client.get("/api/v1/virtual/helm-test/index.yaml")
|
response = client.get("/api/v1/virtual/helm-test/index.yaml")
|
||||||
assert response.status_code == 404
|
assert response.status_code == 400
|
||||||
|
|
||||||
def test_unsupported_package_returns_400(self, client, patched_virtual_deps):
|
def test_unsupported_package_returns_400(self, client, patched_virtual_deps):
|
||||||
# unsupported-virtual-test has package "rpm"
|
# unsupported-virtual-test has package "rpm"
|
||||||
|
|||||||
Reference in New Issue
Block a user