Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 67cedf9bba | |||
| 8d9bc1c422 | |||
| 30b7cef026 | |||
| 603be5b989 | |||
| 9eba49500c | |||
| 0083d67272 | |||
| 8ec7de50e3 | |||
| 9c465cbd4c | |||
| ee6e581b9d | |||
| 2a8e544de3 | |||
| 847eeb839f | |||
| 74d9c0fa84 | |||
| 097fbf0016 | |||
| 6f8e70c27a |
@@ -0,0 +1,24 @@
|
|||||||
|
repos:
|
||||||
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
|
rev: v5.0.0
|
||||||
|
hooks:
|
||||||
|
- id: trailing-whitespace
|
||||||
|
- id: end-of-file-fixer
|
||||||
|
- id: check-yaml
|
||||||
|
- id: check-added-large-files
|
||||||
|
- id: check-merge-conflict
|
||||||
|
|
||||||
|
- repo: https://github.com/dnephin/pre-commit-golang
|
||||||
|
rev: v0.5.1
|
||||||
|
hooks:
|
||||||
|
- id: go-fmt
|
||||||
|
- id: go-mod-tidy
|
||||||
|
|
||||||
|
- repo: local
|
||||||
|
hooks:
|
||||||
|
- id: go-vet
|
||||||
|
name: go vet
|
||||||
|
entry: go vet ./...
|
||||||
|
language: system
|
||||||
|
types: [go]
|
||||||
|
pass_filenames: false
|
||||||
@@ -8,6 +8,8 @@ steps:
|
|||||||
settings:
|
settings:
|
||||||
registry: git.unkin.net
|
registry: git.unkin.net
|
||||||
repo: git.unkin.net/unkin/artifactapi
|
repo: git.unkin.net/unkin/artifactapi
|
||||||
|
build_args:
|
||||||
|
VERSION: ${CI_COMMIT_TAG}
|
||||||
username: droneci
|
username: droneci
|
||||||
password:
|
password:
|
||||||
from_secret: DRONECI_PASSWORD
|
from_secret: DRONECI_PASSWORD
|
||||||
@@ -22,6 +24,8 @@ steps:
|
|||||||
repo: git.unkin.net/unkin/artifactapi-ui
|
repo: git.unkin.net/unkin/artifactapi-ui
|
||||||
dockerfile: ui/Dockerfile.ui
|
dockerfile: ui/Dockerfile.ui
|
||||||
context: ui
|
context: ui
|
||||||
|
build_args:
|
||||||
|
BASE_PATH: /ui
|
||||||
username: droneci
|
username: droneci
|
||||||
password:
|
password:
|
||||||
from_secret: DRONECI_PASSWORD
|
from_secret: DRONECI_PASSWORD
|
||||||
|
|||||||
@@ -3,7 +3,15 @@ when:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: pre-commit
|
- name: pre-commit
|
||||||
image: golang:1.25
|
image: git.unkin.net/unkin/almalinux9-gobuilder:20260606
|
||||||
commands:
|
commands:
|
||||||
- test -z "$(gofmt -l .)"
|
- uvx pre-commit run --all-files
|
||||||
- go vet ./...
|
backend_options:
|
||||||
|
kubernetes:
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: 512Mi
|
||||||
|
cpu: 1
|
||||||
|
limits:
|
||||||
|
memory: 2Gi
|
||||||
|
cpu: 2
|
||||||
|
|||||||
+2
-1
@@ -9,7 +9,8 @@ RUN go mod download
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o artifactapi ./cmd/artifactapi
|
ARG VERSION=dev
|
||||||
|
RUN CGO_ENABLED=0 go build -ldflags="-s -w -X main.version=${VERSION}" -o artifactapi ./cmd/artifactapi
|
||||||
|
|
||||||
FROM gcr.io/distroless/static-debian12:nonroot
|
FROM gcr.io/distroless/static-debian12:nonroot
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ check-go:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
build: check-go tidy
|
build: check-go tidy
|
||||||
go build -ldflags="-s -w" -o $(BINARY) ./cmd/artifactapi
|
go build -ldflags="-s -w -X main.version=$(VERSION)" -o $(BINARY) ./cmd/artifactapi
|
||||||
|
|
||||||
test: check-go
|
test: check-go
|
||||||
go test -race -count=1 ./pkg/... ./internal/...
|
go test -race -count=1 ./pkg/... ./internal/...
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import (
|
|||||||
"git.unkin.net/unkin/artifactapi/internal/tui"
|
"git.unkin.net/unkin/artifactapi/internal/tui"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var version = "dev"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if len(os.Args) > 1 && os.Args[1] == "tui" {
|
if len(os.Args) > 1 && os.Args[1] == "tui" {
|
||||||
endpoint := os.Getenv("ARTIFACTAPI_ENDPOINT")
|
endpoint := os.Getenv("ARTIFACTAPI_ENDPOINT")
|
||||||
@@ -42,7 +44,7 @@ func main() {
|
|||||||
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
srv, err := server.New(cfg)
|
srv, err := server.New(cfg, version)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to create server", "error", err)
|
slog.Error("failed to create server", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
+185
@@ -0,0 +1,185 @@
|
|||||||
|
# Authentication & Authorization — Design
|
||||||
|
|
||||||
|
Status: **proposed** (tracking issue #79)
|
||||||
|
|
||||||
|
Today ArtifactAPI has no authentication: every proxy and management request is
|
||||||
|
served unconditionally. This document describes an auth/authz system that adds
|
||||||
|
identity and path-scoped authorization **without changing behaviour until an
|
||||||
|
operator turns enforcement on** — the default policy is fully open.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Identify callers as one of two principal kinds: **service accounts** and **users**.
|
||||||
|
- Authorize each request against a **path + capability** ACL model.
|
||||||
|
- Let **Vault/OpenBao** mint short-lived tokens so the Terraform provider can get
|
||||||
|
just-in-time credentials to make config changes.
|
||||||
|
- Ship **default-open**: an unconfigured deployment behaves exactly as today.
|
||||||
|
|
||||||
|
## Non-goals (initial phase)
|
||||||
|
|
||||||
|
- Per-object encryption, signing, or content trust.
|
||||||
|
- Rate limiting / quotas (separate concern).
|
||||||
|
- Multi-tenancy beyond what path ACLs express.
|
||||||
|
|
||||||
|
## Principals
|
||||||
|
|
||||||
|
| Kind | Authenticates with | Created by | Lifetime |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Service account — static token | `Authorization: Bearer <token>` | admin via management API | until revoked |
|
||||||
|
| Service account — dynamic token | `Authorization: Bearer <token>` | Vault secrets engine → mint endpoint | lease TTL (auto-revoked) |
|
||||||
|
| User | UI session cookie (OIDC/LDAP login) | external IdP, first-seen on login | session TTL |
|
||||||
|
|
||||||
|
A **service account** is a named identity holding a set of ACL grants. It may
|
||||||
|
have any number of associated tokens (static, or dynamic ones minted by Vault).
|
||||||
|
A **user** is an identity resolved from an external IdP; group membership from
|
||||||
|
the IdP maps to ACL grants.
|
||||||
|
|
||||||
|
## Tokens
|
||||||
|
|
||||||
|
- Format: `aapi_<base62(32 random bytes)>`. The `aapi_` prefix makes tokens
|
||||||
|
greppable and lets us reject obviously-malformed values cheaply.
|
||||||
|
- Storage: only the **SHA-256 of the token** is stored, never the plaintext.
|
||||||
|
Lookup hashes the presented token and matches by hash.
|
||||||
|
- Each token row carries: id, principal (service account) ref, sha256, optional
|
||||||
|
label, `expires_at` (null = non-expiring), `created_at`, `last_used_at`.
|
||||||
|
- Revocation: delete the row (static) or Vault lease revoke → mint endpoint
|
||||||
|
revoke (dynamic).
|
||||||
|
|
||||||
|
## ACL model
|
||||||
|
|
||||||
|
A grant is `(path_pattern, capability)`. A principal is allowed an action iff at
|
||||||
|
least one of its grants matches the request's resource path and capability.
|
||||||
|
|
||||||
|
### Resource paths
|
||||||
|
|
||||||
|
```
|
||||||
|
remote/<remote-name>/<path-in-remote> # proxy + local repo objects
|
||||||
|
virtual/<virtual-name>/<path> # virtual repo reads
|
||||||
|
admin/remotes/<remote-name> # manage a remote definition
|
||||||
|
admin/virtuals/<virtual-name> # manage a virtual definition
|
||||||
|
admin/principals/<name> # manage service accounts / tokens
|
||||||
|
```
|
||||||
|
|
||||||
|
Patterns support a trailing `*` wildcard and `<segment>/*` prefixes, e.g.
|
||||||
|
`remote/dockerhub/*`, `remote/*`, `admin/*`. Matching is longest-prefix by
|
||||||
|
segment; an exact match always wins over a wildcard.
|
||||||
|
|
||||||
|
### Capabilities
|
||||||
|
|
||||||
|
| Capability | Meaning for `remote/...` | Meaning for `admin/...` |
|
||||||
|
|---|---|---|
|
||||||
|
| `read` | GET/HEAD an artifact | GET a definition |
|
||||||
|
| `create` | first upload of a new local file | create a new definition |
|
||||||
|
| `write` | overwrite / re-publish | update an existing definition |
|
||||||
|
| `delete` | remove an object | delete a definition |
|
||||||
|
|
||||||
|
The HTTP layer maps each route to `(resource path, capability)`:
|
||||||
|
|
||||||
|
| Route | Resource | Capability |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET /api/v1/remote/{r}/*`, `/v2/{r}/*` | `remote/{r}/{path}` | `read` |
|
||||||
|
| `GET /api/v1/virtual/{v}/*` | `virtual/{v}/{path}` | `read` |
|
||||||
|
| `PUT /api/v2/remotes/{r}/files/*` (new file) | `remote/{r}/{path}` | `create` |
|
||||||
|
| `PUT ...` (existing file) | `remote/{r}/{path}` | `write` |
|
||||||
|
| `DELETE /api/v2/remotes/{r}/files/*` | `remote/{r}/{path}` | `delete` |
|
||||||
|
| `POST /api/v2/remotes` | `admin/remotes/{name}` | `create` |
|
||||||
|
| `PUT /api/v2/remotes/{r}` | `admin/remotes/{r}` | `write` |
|
||||||
|
| `DELETE /api/v2/remotes/{r}` | `admin/remotes/{r}` | `delete` |
|
||||||
|
|
||||||
|
## Enforcement middleware & default-open
|
||||||
|
|
||||||
|
A single middleware runs before the proxy/management handlers:
|
||||||
|
|
||||||
|
1. Resolve the principal from the request (bearer token → service account, or
|
||||||
|
session cookie → user). No credential → the **anonymous** principal.
|
||||||
|
2. Compute `(resource, capability)` for the route.
|
||||||
|
3. If **enforcement is disabled** (default), allow. Otherwise, evaluate the
|
||||||
|
principal's grants (including the anonymous principal's grants) and allow iff
|
||||||
|
a grant matches; else 401 (no/invalid credential) or 403 (authenticated but
|
||||||
|
unauthorized).
|
||||||
|
|
||||||
|
Enforcement is controlled by a single setting, `AUTH_ENFORCE` (default `false`).
|
||||||
|
While `false`, the middleware still *resolves* the principal (so `last_used_at`
|
||||||
|
and audit logging work) but never denies — making rollout observable before it
|
||||||
|
is enforced. The **anonymous** principal is seeded with `*` → all capabilities,
|
||||||
|
so even flipping `AUTH_ENFORCE=true` with no other config keeps the deployment
|
||||||
|
open until an admin tightens the anonymous grants.
|
||||||
|
|
||||||
|
## Vault integration
|
||||||
|
|
||||||
|
### Mint endpoint (artifactapi side)
|
||||||
|
|
||||||
|
`POST /api/v2/auth/tokens:mint` — restricted to callers Vault trusts. It creates
|
||||||
|
a dynamic token bound to a named service account with a caller-supplied TTL, and
|
||||||
|
returns the plaintext once. `DELETE /api/v2/auth/tokens/{id}` revokes it.
|
||||||
|
|
||||||
|
Trust between Vault and artifactapi: a dedicated **bootstrap service account**
|
||||||
|
whose static token is stored in Vault's engine `config`. The mint endpoint
|
||||||
|
requires `admin/principals/*: write`. (mTLS is a future hardening option.)
|
||||||
|
|
||||||
|
### `vault-plugin-secrets-artifactapi` (new repo)
|
||||||
|
|
||||||
|
Mirrors [`vault-plugin-secrets-litellm`](https://git.unkin.net/unkin/vault-plugin-secrets-litellm):
|
||||||
|
HashiCorp `vault/sdk`, OpenBao-compatible single binary. Paths:
|
||||||
|
|
||||||
|
- `config` — artifactapi base URL + bootstrap token.
|
||||||
|
- `roles/<name>` — target service account + default/max TTL.
|
||||||
|
- `creds/<name>` — mint a dynamic token (calls the mint endpoint); the Vault
|
||||||
|
lease's revoke calls the revoke endpoint.
|
||||||
|
|
||||||
|
E2e (`make e2e`) spins Postgres + MinIO + Redis + artifactapi + Vault + OpenBao
|
||||||
|
in Docker and exercises the full lease lifecycle against both engines. On the
|
||||||
|
Fedora host all bind mounts need `:z` (SELinux).
|
||||||
|
|
||||||
|
## User login (OIDC/LDAP) & UI
|
||||||
|
|
||||||
|
- `GET /api/v2/auth/login` starts an OIDC auth-code flow (or LDAP bind form);
|
||||||
|
`GET /api/v2/auth/callback` establishes a signed session cookie.
|
||||||
|
- IdP groups map to service-account-style grants via configurable group→grant
|
||||||
|
rules. Existing infra: `terraform-authentik`, `terraform-ldap`.
|
||||||
|
- The React UI gains a login state and sends the session cookie; management
|
||||||
|
screens hide actions the principal lacks.
|
||||||
|
|
||||||
|
## Terraform provider
|
||||||
|
|
||||||
|
`terraform-provider-artifactapi` gains a `token` attribute (and
|
||||||
|
`ARTIFACTAPI_TOKEN` env var) sent as `Authorization: Bearer`. In CI the token is
|
||||||
|
sourced from the Vault engine above, so config changes use short-lived creds.
|
||||||
|
|
||||||
|
## Data model (new tables, additive migration)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
service_accounts(name PK, description, disabled, created_at)
|
||||||
|
auth_tokens(id PK, principal TEXT REFERENCES service_accounts(name) ON DELETE CASCADE,
|
||||||
|
token_sha256 TEXT UNIQUE, label, expires_at, created_at, last_used_at)
|
||||||
|
acl_grants(id PK, principal TEXT, path_pattern TEXT, capability TEXT,
|
||||||
|
UNIQUE(principal, path_pattern, capability))
|
||||||
|
-- principal = a service account name, the reserved 'anonymous', or 'user:<sub>'
|
||||||
|
```
|
||||||
|
|
||||||
|
All tables are created with `CREATE TABLE IF NOT EXISTS` alongside the existing
|
||||||
|
inline migrations; adding them changes no current behaviour.
|
||||||
|
|
||||||
|
## Rollout / phased delivery
|
||||||
|
|
||||||
|
Each phase is a separate PR; the system stays open until phase 6 is deliberately
|
||||||
|
enabled.
|
||||||
|
|
||||||
|
1. **Data model + resolution** — tables, token hashing, principal resolution
|
||||||
|
middleware in **observe-only** mode (never denies). Seed anonymous `*`.
|
||||||
|
2. **ACL evaluation** — grant matching + `(resource, capability)` route mapping,
|
||||||
|
still gated by `AUTH_ENFORCE=false`.
|
||||||
|
3. **Management API** — CRUD for service accounts, tokens, grants.
|
||||||
|
4. **Vault mint/revoke endpoints** + bootstrap trust.
|
||||||
|
5. **`vault-plugin-secrets-artifactapi`** (new repo) + `terraform-vault` role,
|
||||||
|
policies; `argocd-apps` deploy.
|
||||||
|
6. **OIDC/LDAP user login + UI**, Terraform provider `token`, and the switch to
|
||||||
|
enable enforcement in an environment.
|
||||||
|
|
||||||
|
## Cross-repo dependencies
|
||||||
|
|
||||||
|
- `terraform-vault` — mount the secrets engine, define `roles/*`, ACL policies,
|
||||||
|
and the K8s auth role the Terraform CI uses.
|
||||||
|
- `argocd-apps` — deploy the plugin sidecar/init and any ServiceAccount.
|
||||||
|
- `terraform-provider-artifactapi` — `token` attribute.
|
||||||
|
- `terraform-authentik` / `terraform-ldap` — IdP client + group mappings.
|
||||||
@@ -3,6 +3,7 @@ module git.unkin.net/unkin/artifactapi
|
|||||||
go 1.25.9
|
go 1.25.9
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/cavaliergopher/rpm v1.3.0
|
||||||
github.com/charmbracelet/bubbletea v1.3.10
|
github.com/charmbracelet/bubbletea v1.3.10
|
||||||
github.com/charmbracelet/lipgloss v1.1.0
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
github.com/go-chi/chi/v5 v5.3.0
|
github.com/go-chi/chi/v5 v5.3.0
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
|||||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||||
|
github.com/cavaliergopher/rpm v1.3.0 h1:UHX46sasX8MesUXXQ+UbkFLUX4eUWTlEcX8jcnRBIgI=
|
||||||
|
github.com/cavaliergopher/rpm v1.3.0/go.mod h1:vEumo1vvtrHM1Ov86f6+k8j7zNKOxQfHDCAIcR/36ZI=
|
||||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
|||||||
@@ -37,6 +37,20 @@ func (h *ProxyHandler) Routes() chi.Router {
|
|||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *ProxyHandler) DockerV2Routes() chi.Router {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Get("/", h.handleDockerPing)
|
||||||
|
r.Head("/", h.handleDockerPing)
|
||||||
|
r.Get("/{remoteName}/*", h.handleProxy)
|
||||||
|
r.Head("/{remoteName}/*", h.handleProxy)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ProxyHandler) handleDockerPing(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Docker-Distribution-Api-Version", "registry/2.0")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *ProxyHandler) handleProxy(w http.ResponseWriter, r *http.Request) {
|
func (h *ProxyHandler) handleProxy(w http.ResponseWriter, r *http.Request) {
|
||||||
remoteName := chi.URLParam(r, "remoteName")
|
remoteName := chi.URLParam(r, "remoteName")
|
||||||
path := chi.URLParam(r, "*")
|
path := chi.URLParam(r, "*")
|
||||||
@@ -53,7 +67,7 @@ func (h *ProxyHandler) handleProxy(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := h.engine.Fetch(r.Context(), *remote, path, prov)
|
result, err := h.engine.Fetch(r.Context(), *remote, path, prov, r.Header)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var proxyErr *proxy.ProxyError
|
var proxyErr *proxy.ProxyError
|
||||||
if errors.As(err, &proxyErr) {
|
if errors.As(err, &proxyErr) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package v2
|
package v2
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -58,14 +59,14 @@ func (h *LocalHandler) upload(w http.ResponseWriter, r *http.Request) {
|
|||||||
prov, _ := provider.Get(remote.PackageType)
|
prov, _ := provider.Get(remote.PackageType)
|
||||||
|
|
||||||
if uploader, ok := prov.(provider.LocalUploader); ok {
|
if uploader, ok := prov.(provider.LocalUploader); ok {
|
||||||
h.uploadValidated(w, r, remote, filePath, uploader)
|
h.uploadValidated(w, r, remote, filePath, prov, uploader)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
h.uploadGeneric(w, r, remote, filePath)
|
h.uploadGeneric(w, r, remote, filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *LocalHandler) uploadValidated(w http.ResponseWriter, r *http.Request, remote *models.Remote, filePath string, uploader provider.LocalUploader) {
|
func (h *LocalHandler) uploadValidated(w http.ResponseWriter, r *http.Request, remote *models.Remote, filePath string, prov provider.Provider, uploader provider.LocalUploader) {
|
||||||
storagePath, contentType, err := uploader.ValidateUpload(filePath)
|
storagePath, contentType, err := uploader.ValidateUpload(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
@@ -102,6 +103,10 @@ func (h *LocalHandler) uploadValidated(w http.ResponseWriter, r *http.Request, r
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if hook, ok := prov.(provider.PostUploadHook); ok {
|
||||||
|
go hook.AfterUpload(context.Background(), remote.Name, storagePath, result.ContentHash, h, h.db)
|
||||||
|
}
|
||||||
|
|
||||||
writeJSON(w, http.StatusCreated, uploader.UploadResponse(storagePath, result.ContentHash, result.SizeBytes))
|
writeJSON(w, http.StatusCreated, uploader.UploadResponse(storagePath, result.ContentHash, result.SizeBytes))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,3 +195,11 @@ func (h *LocalHandler) remove(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (h *LocalHandler) DB() *database.DB {
|
func (h *LocalHandler) DB() *database.DB {
|
||||||
return h.db
|
return h.db
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *LocalHandler) Download(ctx context.Context, key string) (io.ReadCloser, int64, error) {
|
||||||
|
reader, info, err := h.store.Download(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
return reader, info.Size, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -124,6 +124,37 @@ func (db *DB) migrate() error {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_access_log_remote_time ON access_log(remote_name, created_at);
|
CREATE INDEX IF NOT EXISTS idx_access_log_remote_time ON access_log(remote_name, created_at);
|
||||||
|
|
||||||
ALTER TABLE remotes ADD COLUMN IF NOT EXISTS repo_type TEXT DEFAULT 'remote';
|
ALTER TABLE remotes ADD COLUMN IF NOT EXISTS repo_type TEXT DEFAULT 'remote';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS rpm_metadata (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
repo_name TEXT NOT NULL,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
content_hash TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
epoch INTEGER DEFAULT 0,
|
||||||
|
version TEXT NOT NULL,
|
||||||
|
release TEXT NOT NULL,
|
||||||
|
arch TEXT NOT NULL,
|
||||||
|
summary TEXT DEFAULT '',
|
||||||
|
description TEXT DEFAULT '',
|
||||||
|
rpm_size BIGINT DEFAULT 0,
|
||||||
|
installed_size BIGINT DEFAULT 0,
|
||||||
|
license TEXT DEFAULT '',
|
||||||
|
vendor TEXT DEFAULT '',
|
||||||
|
build_group TEXT DEFAULT '',
|
||||||
|
build_host TEXT DEFAULT '',
|
||||||
|
source_rpm TEXT DEFAULT '',
|
||||||
|
url TEXT DEFAULT '',
|
||||||
|
packager TEXT DEFAULT '',
|
||||||
|
requires JSONB DEFAULT '[]',
|
||||||
|
provides JSONB DEFAULT '[]',
|
||||||
|
files JSONB DEFAULT '[]',
|
||||||
|
changelogs JSONB DEFAULT '[]',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(repo_name, file_path)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_rpm_metadata_repo ON rpm_metadata(repo_name);
|
||||||
`)
|
`)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"git.unkin.net/unkin/artifactapi/internal/provider"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (db *DB) InsertRPMMetadata(ctx context.Context, meta *provider.RPMMetadata) error {
|
||||||
|
requiresJSON, _ := json.Marshal(meta.Requires)
|
||||||
|
providesJSON, _ := json.Marshal(meta.Provides)
|
||||||
|
filesJSON, _ := json.Marshal(meta.Files)
|
||||||
|
changelogsJSON, _ := json.Marshal(meta.Changelogs)
|
||||||
|
|
||||||
|
_, err := db.Pool.Exec(ctx, `
|
||||||
|
INSERT INTO rpm_metadata (
|
||||||
|
repo_name, file_path, content_hash,
|
||||||
|
name, epoch, version, release, arch,
|
||||||
|
summary, description, rpm_size, installed_size,
|
||||||
|
license, vendor, build_group, build_host, source_rpm, url, packager,
|
||||||
|
requires, provides, files, changelogs
|
||||||
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23)
|
||||||
|
ON CONFLICT (repo_name, file_path) DO NOTHING
|
||||||
|
`,
|
||||||
|
meta.RepoName, meta.FilePath, meta.ContentHash,
|
||||||
|
meta.Name, meta.Epoch, meta.Version, meta.Release, meta.Arch,
|
||||||
|
meta.Summary, meta.Description, meta.RPMSize, meta.InstalledSize,
|
||||||
|
meta.License, meta.Vendor, meta.Group, meta.BuildHost, meta.SourceRPM, meta.URL, meta.Packager,
|
||||||
|
requiresJSON, providesJSON, filesJSON, changelogsJSON,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
type RPMMetadataRow struct {
|
||||||
|
RepoName string
|
||||||
|
FilePath string
|
||||||
|
ContentHash string
|
||||||
|
Name string
|
||||||
|
Epoch int
|
||||||
|
Version string
|
||||||
|
Release string
|
||||||
|
Arch string
|
||||||
|
Summary string
|
||||||
|
Description string
|
||||||
|
RPMSize int64
|
||||||
|
InstalledSize int64
|
||||||
|
License string
|
||||||
|
Vendor string
|
||||||
|
Group string
|
||||||
|
BuildHost string
|
||||||
|
SourceRPM string
|
||||||
|
URL string
|
||||||
|
Packager string
|
||||||
|
Requires json.RawMessage
|
||||||
|
Provides json.RawMessage
|
||||||
|
Files json.RawMessage
|
||||||
|
Changelogs json.RawMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) ListRPMMetadataEntries(ctx context.Context, repoName string) ([]provider.RPMMetadata, error) {
|
||||||
|
rows, err := db.ListRPMMetadata(ctx, repoName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := make([]provider.RPMMetadata, len(rows))
|
||||||
|
for i, r := range rows {
|
||||||
|
meta := provider.RPMMetadata{
|
||||||
|
RepoName: r.RepoName,
|
||||||
|
FilePath: r.FilePath,
|
||||||
|
ContentHash: r.ContentHash,
|
||||||
|
Name: r.Name,
|
||||||
|
Epoch: r.Epoch,
|
||||||
|
Version: r.Version,
|
||||||
|
Release: r.Release,
|
||||||
|
Arch: r.Arch,
|
||||||
|
Summary: r.Summary,
|
||||||
|
Description: r.Description,
|
||||||
|
RPMSize: r.RPMSize,
|
||||||
|
InstalledSize: r.InstalledSize,
|
||||||
|
License: r.License,
|
||||||
|
Vendor: r.Vendor,
|
||||||
|
Group: r.Group,
|
||||||
|
BuildHost: r.BuildHost,
|
||||||
|
SourceRPM: r.SourceRPM,
|
||||||
|
URL: r.URL,
|
||||||
|
Packager: r.Packager,
|
||||||
|
}
|
||||||
|
json.Unmarshal(r.Requires, &meta.Requires)
|
||||||
|
json.Unmarshal(r.Provides, &meta.Provides)
|
||||||
|
json.Unmarshal(r.Files, &meta.Files)
|
||||||
|
json.Unmarshal(r.Changelogs, &meta.Changelogs)
|
||||||
|
result[i] = meta
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) ListRPMMetadata(ctx context.Context, repoName string) ([]RPMMetadataRow, error) {
|
||||||
|
rows, err := db.Pool.Query(ctx, `
|
||||||
|
SELECT repo_name, file_path, content_hash,
|
||||||
|
name, epoch, version, release, arch,
|
||||||
|
summary, description, rpm_size, installed_size,
|
||||||
|
license, vendor, build_group, build_host, source_rpm, url, packager,
|
||||||
|
requires, provides, files, changelogs
|
||||||
|
FROM rpm_metadata
|
||||||
|
WHERE repo_name = $1
|
||||||
|
ORDER BY name, epoch, version, release, arch
|
||||||
|
`, repoName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var result []RPMMetadataRow
|
||||||
|
for rows.Next() {
|
||||||
|
var r RPMMetadataRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&r.RepoName, &r.FilePath, &r.ContentHash,
|
||||||
|
&r.Name, &r.Epoch, &r.Version, &r.Release, &r.Arch,
|
||||||
|
&r.Summary, &r.Description, &r.RPMSize, &r.InstalledSize,
|
||||||
|
&r.License, &r.Vendor, &r.Group, &r.BuildHost, &r.SourceRPM, &r.URL, &r.Packager,
|
||||||
|
&r.Requires, &r.Provides, &r.Files, &r.Changelogs,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result = append(result, r)
|
||||||
|
}
|
||||||
|
return result, rows.Err()
|
||||||
|
}
|
||||||
@@ -30,6 +30,15 @@ func (db *DB) GetOverviewStats(ctx context.Context) (*models.OverviewStats, erro
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = db.Pool.QueryRow(ctx, `
|
||||||
|
SELECT COALESCE(SUM(size_bytes), 0)
|
||||||
|
FROM access_log
|
||||||
|
WHERE cache_hit = TRUE AND created_at > NOW() - INTERVAL '30 days'
|
||||||
|
`).Scan(&stats.BandwidthSaved30d)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return &stats, nil
|
return &stats, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package provider
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"git.unkin.net/unkin/artifactapi/pkg/models"
|
"git.unkin.net/unkin/artifactapi/pkg/models"
|
||||||
@@ -44,6 +45,67 @@ type LocalIndexer interface {
|
|||||||
GenerateLocalIndex(ctx context.Context, files FileStore, repoName, path string) ([]byte, error)
|
GenerateLocalIndex(ctx context.Context, files FileStore, repoName, path string) ([]byte, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type BlobReader interface {
|
||||||
|
Download(ctx context.Context, key string) (io.ReadCloser, int64, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type PostUploadHook interface {
|
||||||
|
AfterUpload(ctx context.Context, repoName, storagePath, contentHash string, blobs BlobReader, db MetadataStore)
|
||||||
|
}
|
||||||
|
|
||||||
|
type MetadataStore interface {
|
||||||
|
InsertRPMMetadata(ctx context.Context, meta *RPMMetadata) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type RPMMetadataReader interface {
|
||||||
|
ListRPMMetadataEntries(ctx context.Context, repoName string) ([]RPMMetadata, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type RPMMetadata struct {
|
||||||
|
RepoName string
|
||||||
|
FilePath string
|
||||||
|
ContentHash string
|
||||||
|
Name string
|
||||||
|
Epoch int
|
||||||
|
Version string
|
||||||
|
Release string
|
||||||
|
Arch string
|
||||||
|
Summary string
|
||||||
|
Description string
|
||||||
|
RPMSize int64
|
||||||
|
InstalledSize int64
|
||||||
|
License string
|
||||||
|
Vendor string
|
||||||
|
Group string
|
||||||
|
BuildHost string
|
||||||
|
SourceRPM string
|
||||||
|
URL string
|
||||||
|
Packager string
|
||||||
|
Requires []RPMDep
|
||||||
|
Provides []RPMDep
|
||||||
|
Files []RPMFile
|
||||||
|
Changelogs []RPMChangelog
|
||||||
|
}
|
||||||
|
|
||||||
|
type RPMDep struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Flags string `json:"flags,omitempty"`
|
||||||
|
Epoch string `json:"epoch,omitempty"`
|
||||||
|
Version string `json:"version,omitempty"`
|
||||||
|
Release string `json:"release,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RPMFile struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RPMChangelog struct {
|
||||||
|
Author string `json:"author"`
|
||||||
|
Date int64 `json:"date"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
|
||||||
type IndexMerger interface {
|
type IndexMerger interface {
|
||||||
MergeIndexes(members []MemberIndex, proxyBaseURL string) ([]byte, error)
|
MergeIndexes(members []MemberIndex, proxyBaseURL string) ([]byte, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,24 @@
|
|||||||
package rpm
|
package rpm
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
rpmlib "github.com/cavaliergopher/rpm"
|
||||||
|
|
||||||
"git.unkin.net/unkin/artifactapi/internal/auth"
|
"git.unkin.net/unkin/artifactapi/internal/auth"
|
||||||
"git.unkin.net/unkin/artifactapi/internal/provider"
|
"git.unkin.net/unkin/artifactapi/internal/provider"
|
||||||
|
"git.unkin.net/unkin/artifactapi/internal/storage"
|
||||||
"git.unkin.net/unkin/artifactapi/pkg/models"
|
"git.unkin.net/unkin/artifactapi/pkg/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -55,3 +66,379 @@ func (p *Provider) RewriteResponse(_ []byte, _ models.Remote, _ string) ([]byte,
|
|||||||
func (p *Provider) AuthHeaders(_ context.Context, remote models.Remote) (http.Header, error) {
|
func (p *Provider) AuthHeaders(_ context.Context, remote models.Remote) (http.Header, error) {
|
||||||
return auth.BasicHeaders(remote), nil
|
return auth.BasicHeaders(remote), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *Provider) ValidateUpload(filePath string) (storagePath, contentType string, err error) {
|
||||||
|
filename := filePath
|
||||||
|
if idx := strings.LastIndex(filePath, "/"); idx >= 0 {
|
||||||
|
filename = filePath[idx+1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasSuffix(strings.ToLower(filename), ".rpm") {
|
||||||
|
return "", "", fmt.Errorf("file must be an .rpm package")
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Packages/" + filename, "application/x-rpm", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) UploadResponse(storagePath, contentHash string, sizeBytes int64) map[string]any {
|
||||||
|
filename := strings.TrimPrefix(storagePath, "Packages/")
|
||||||
|
return map[string]any{
|
||||||
|
"filename": filename,
|
||||||
|
"content_hash": contentHash,
|
||||||
|
"size_bytes": sizeBytes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) AfterUpload(ctx context.Context, repoName, storagePath, contentHash string, blobs provider.BlobReader, db provider.MetadataStore) {
|
||||||
|
s3Key := storage.BlobKey(strings.TrimPrefix(contentHash, "sha256:"))
|
||||||
|
|
||||||
|
reader, blobSize, err := blobs.Download(ctx, s3Key)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("rpm metadata: download failed", "repo", repoName, "path", storagePath, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
|
||||||
|
pkg, err := rpmlib.Read(reader)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("rpm metadata: parse failed", "repo", repoName, "path", storagePath, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
meta := &provider.RPMMetadata{
|
||||||
|
RepoName: repoName,
|
||||||
|
FilePath: storagePath,
|
||||||
|
ContentHash: contentHash,
|
||||||
|
Name: pkg.Name(),
|
||||||
|
Epoch: pkg.Epoch(),
|
||||||
|
Version: pkg.Version(),
|
||||||
|
Release: pkg.Release(),
|
||||||
|
Arch: pkg.Architecture(),
|
||||||
|
Summary: pkg.Summary(),
|
||||||
|
Description: pkg.Description(),
|
||||||
|
RPMSize: blobSize,
|
||||||
|
InstalledSize: int64(pkg.Size()),
|
||||||
|
License: pkg.License(),
|
||||||
|
Vendor: pkg.Vendor(),
|
||||||
|
Group: firstGroup(pkg.Groups()),
|
||||||
|
BuildHost: pkg.BuildHost(),
|
||||||
|
SourceRPM: pkg.SourceRPM(),
|
||||||
|
URL: pkg.URL(),
|
||||||
|
Packager: pkg.Packager(),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, req := range pkg.Requires() {
|
||||||
|
meta.Requires = append(meta.Requires, rpmDepFromEntry(req))
|
||||||
|
}
|
||||||
|
for _, prov := range pkg.Provides() {
|
||||||
|
meta.Provides = append(meta.Provides, rpmDepFromEntry(prov))
|
||||||
|
}
|
||||||
|
|
||||||
|
if meta.Requires == nil {
|
||||||
|
meta.Requires = []provider.RPMDep{}
|
||||||
|
}
|
||||||
|
if meta.Provides == nil {
|
||||||
|
meta.Provides = []provider.RPMDep{}
|
||||||
|
}
|
||||||
|
meta.Files = []provider.RPMFile{}
|
||||||
|
meta.Changelogs = []provider.RPMChangelog{}
|
||||||
|
|
||||||
|
if err := db.InsertRPMMetadata(ctx, meta); err != nil {
|
||||||
|
slog.Error("rpm metadata: insert failed", "repo", repoName, "path", storagePath, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("rpm metadata: parsed", "repo", repoName, "name", meta.Name, "version", meta.Version, "arch", meta.Arch)
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpmDepFromEntry(e rpmlib.Dependency) provider.RPMDep {
|
||||||
|
dep := provider.RPMDep{Name: e.Name()}
|
||||||
|
if e.Flags() != 0 {
|
||||||
|
dep.Flags = rpmFlagString(e.Flags())
|
||||||
|
dep.Version = e.Version()
|
||||||
|
dep.Release = e.Release()
|
||||||
|
if e.Epoch() > 0 {
|
||||||
|
dep.Epoch = fmt.Sprintf("%d", e.Epoch())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dep
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpmFlagString(f int) string {
|
||||||
|
switch {
|
||||||
|
case f&0x08 != 0 && f&0x04 != 0:
|
||||||
|
return "GE"
|
||||||
|
case f&0x02 != 0 && f&0x04 != 0:
|
||||||
|
return "LE"
|
||||||
|
case f&0x08 != 0:
|
||||||
|
return "GT"
|
||||||
|
case f&0x02 != 0:
|
||||||
|
return "LT"
|
||||||
|
case f&0x04 != 0:
|
||||||
|
return "EQ"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstGroup(groups []string) string {
|
||||||
|
if len(groups) > 0 {
|
||||||
|
return groups[0]
|
||||||
|
}
|
||||||
|
return "Unspecified"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) ServeLocalIndex(w http.ResponseWriter, r *http.Request, files provider.FileStore, repoName, path string) bool {
|
||||||
|
if !strings.HasPrefix(path, "repodata/") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
rpmReader, ok := files.(provider.RPMMetadataReader)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "rpm metadata not available", http.StatusInternalServerError)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
tail := strings.TrimPrefix(path, "repodata/")
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case tail == "repomd.xml":
|
||||||
|
p.serveRepomd(w, r, rpmReader, repoName)
|
||||||
|
case strings.HasSuffix(tail, "-primary.xml.gz"):
|
||||||
|
p.servePrimary(w, r, rpmReader, repoName)
|
||||||
|
case strings.HasSuffix(tail, "-filelists.xml.gz"):
|
||||||
|
p.serveFilelists(w, r, rpmReader, repoName)
|
||||||
|
case strings.HasSuffix(tail, "-other.xml.gz"):
|
||||||
|
p.serveOther(w, r, rpmReader, repoName)
|
||||||
|
default:
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) GenerateLocalIndex(ctx context.Context, files provider.FileStore, repoName, path string) ([]byte, error) {
|
||||||
|
return nil, fmt.Errorf("rpm local index generation for virtual repos not supported")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) serveRepomd(w http.ResponseWriter, r *http.Request, reader provider.RPMMetadataReader, repoName string) {
|
||||||
|
metas, err := reader.ListRPMMetadataEntries(r.Context(), repoName)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
primary := generatePrimaryXMLGZ(metas)
|
||||||
|
filelists := generateFilelistsXMLGZ(metas)
|
||||||
|
other := generateOtherXMLGZ(metas)
|
||||||
|
|
||||||
|
primaryHash := sha256Hex(primary)
|
||||||
|
filelistsHash := sha256Hex(filelists)
|
||||||
|
otherHash := sha256Hex(other)
|
||||||
|
|
||||||
|
repomd := generateRepomd(primaryHash, len(primary), filelistsHash, len(filelists), otherHash, len(other))
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/xml")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write(repomd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) servePrimary(w http.ResponseWriter, r *http.Request, reader provider.RPMMetadataReader, repoName string) {
|
||||||
|
metas, err := reader.ListRPMMetadataEntries(r.Context(), repoName)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/gzip")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write(generatePrimaryXMLGZ(metas))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) serveFilelists(w http.ResponseWriter, r *http.Request, reader provider.RPMMetadataReader, repoName string) {
|
||||||
|
metas, err := reader.ListRPMMetadataEntries(r.Context(), repoName)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/gzip")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write(generateFilelistsXMLGZ(metas))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) serveOther(w http.ResponseWriter, r *http.Request, reader provider.RPMMetadataReader, repoName string) {
|
||||||
|
metas, err := reader.ListRPMMetadataEntries(r.Context(), repoName)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/gzip")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write(generateOtherXMLGZ(metas))
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateRepomd(primaryHash string, primarySize int, filelistsHash string, filelistsSize int, otherHash string, otherSize int) []byte {
|
||||||
|
ts := fmt.Sprintf("%d", time.Now().Unix())
|
||||||
|
var b bytes.Buffer
|
||||||
|
b.WriteString(xml.Header)
|
||||||
|
b.WriteString(`<repomd xmlns="http://linux.duke.edu/metadata/repo" xmlns:rpm="http://linux.duke.edu/metadata/rpm">` + "\n")
|
||||||
|
fmt.Fprintf(&b, " <revision>%s</revision>\n", ts)
|
||||||
|
|
||||||
|
writeRepomdData(&b, "primary", primaryHash, primarySize, ts)
|
||||||
|
writeRepomdData(&b, "filelists", filelistsHash, filelistsSize, ts)
|
||||||
|
writeRepomdData(&b, "other", otherHash, otherSize, ts)
|
||||||
|
|
||||||
|
b.WriteString("</repomd>\n")
|
||||||
|
return b.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeRepomdData(b *bytes.Buffer, dtype, hash string, size int, ts string) {
|
||||||
|
fmt.Fprintf(b, " <data type=\"%s\">\n", dtype)
|
||||||
|
fmt.Fprintf(b, " <checksum type=\"sha256\">%s</checksum>\n", hash)
|
||||||
|
fmt.Fprintf(b, " <location href=\"repodata/%s-%s.xml.gz\"/>\n", hash, dtype)
|
||||||
|
fmt.Fprintf(b, " <timestamp>%s</timestamp>\n", ts)
|
||||||
|
fmt.Fprintf(b, " <size>%d</size>\n", size)
|
||||||
|
fmt.Fprintf(b, " </data>\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func generatePrimaryXMLGZ(metas []provider.RPMMetadata) []byte {
|
||||||
|
var xmlBuf bytes.Buffer
|
||||||
|
xmlBuf.WriteString(xml.Header)
|
||||||
|
fmt.Fprintf(&xmlBuf, "<metadata xmlns=\"http://linux.duke.edu/metadata/common\" xmlns:rpm=\"http://linux.duke.edu/metadata/rpm\" packages=\"%d\">\n", len(metas))
|
||||||
|
|
||||||
|
for _, m := range metas {
|
||||||
|
pkgHash := strings.TrimPrefix(m.ContentHash, "sha256:")
|
||||||
|
fmt.Fprintf(&xmlBuf, "<package type=\"rpm\">\n")
|
||||||
|
fmt.Fprintf(&xmlBuf, " <name>%s</name>\n", xmlEscape(m.Name))
|
||||||
|
fmt.Fprintf(&xmlBuf, " <arch>%s</arch>\n", xmlEscape(m.Arch))
|
||||||
|
fmt.Fprintf(&xmlBuf, " <version epoch=\"%d\" ver=\"%s\" rel=\"%s\"/>\n", m.Epoch, xmlEscape(m.Version), xmlEscape(m.Release))
|
||||||
|
fmt.Fprintf(&xmlBuf, " <checksum type=\"sha256\" pkgid=\"YES\">%s</checksum>\n", pkgHash)
|
||||||
|
fmt.Fprintf(&xmlBuf, " <summary>%s</summary>\n", xmlEscape(m.Summary))
|
||||||
|
fmt.Fprintf(&xmlBuf, " <description>%s</description>\n", xmlEscape(m.Description))
|
||||||
|
if m.Packager != "" {
|
||||||
|
fmt.Fprintf(&xmlBuf, " <packager>%s</packager>\n", xmlEscape(m.Packager))
|
||||||
|
}
|
||||||
|
if m.URL != "" {
|
||||||
|
fmt.Fprintf(&xmlBuf, " <url>%s</url>\n", xmlEscape(m.URL))
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&xmlBuf, " <time file=\"%d\" build=\"0\"/>\n", time.Now().Unix())
|
||||||
|
fmt.Fprintf(&xmlBuf, " <size package=\"%d\" installed=\"%d\" archive=\"0\"/>\n", m.RPMSize, m.InstalledSize)
|
||||||
|
fmt.Fprintf(&xmlBuf, " <location href=\"%s\"/>\n", xmlEscape(m.FilePath))
|
||||||
|
fmt.Fprintf(&xmlBuf, " <format>\n")
|
||||||
|
if m.License != "" {
|
||||||
|
fmt.Fprintf(&xmlBuf, " <rpm:license>%s</rpm:license>\n", xmlEscape(m.License))
|
||||||
|
}
|
||||||
|
if m.Vendor != "" {
|
||||||
|
fmt.Fprintf(&xmlBuf, " <rpm:vendor>%s</rpm:vendor>\n", xmlEscape(m.Vendor))
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&xmlBuf, " <rpm:group>%s</rpm:group>\n", xmlEscape(m.Group))
|
||||||
|
if m.BuildHost != "" {
|
||||||
|
fmt.Fprintf(&xmlBuf, " <rpm:buildhost>%s</rpm:buildhost>\n", xmlEscape(m.BuildHost))
|
||||||
|
}
|
||||||
|
if m.SourceRPM != "" {
|
||||||
|
fmt.Fprintf(&xmlBuf, " <rpm:sourcerpm>%s</rpm:sourcerpm>\n", xmlEscape(m.SourceRPM))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(m.Provides) > 0 {
|
||||||
|
xmlBuf.WriteString(" <rpm:provides>\n")
|
||||||
|
for _, d := range m.Provides {
|
||||||
|
writeRPMEntry(&xmlBuf, d)
|
||||||
|
}
|
||||||
|
xmlBuf.WriteString(" </rpm:provides>\n")
|
||||||
|
}
|
||||||
|
if len(m.Requires) > 0 {
|
||||||
|
xmlBuf.WriteString(" <rpm:requires>\n")
|
||||||
|
for _, d := range m.Requires {
|
||||||
|
writeRPMEntry(&xmlBuf, d)
|
||||||
|
}
|
||||||
|
xmlBuf.WriteString(" </rpm:requires>\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(&xmlBuf, " </format>\n")
|
||||||
|
fmt.Fprintf(&xmlBuf, "</package>\n")
|
||||||
|
}
|
||||||
|
xmlBuf.WriteString("</metadata>\n")
|
||||||
|
|
||||||
|
return gzipBytes(xmlBuf.Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateFilelistsXMLGZ(metas []provider.RPMMetadata) []byte {
|
||||||
|
var xmlBuf bytes.Buffer
|
||||||
|
xmlBuf.WriteString(xml.Header)
|
||||||
|
fmt.Fprintf(&xmlBuf, "<filelists xmlns=\"http://linux.duke.edu/metadata/filelists\" packages=\"%d\">\n", len(metas))
|
||||||
|
|
||||||
|
for _, m := range metas {
|
||||||
|
pkgHash := strings.TrimPrefix(m.ContentHash, "sha256:")
|
||||||
|
fmt.Fprintf(&xmlBuf, "<package pkgid=\"%s\" name=\"%s\" arch=\"%s\">\n", pkgHash, xmlEscape(m.Name), xmlEscape(m.Arch))
|
||||||
|
fmt.Fprintf(&xmlBuf, " <version epoch=\"%d\" ver=\"%s\" rel=\"%s\"/>\n", m.Epoch, xmlEscape(m.Version), xmlEscape(m.Release))
|
||||||
|
for _, f := range m.Files {
|
||||||
|
if f.Type != "" {
|
||||||
|
fmt.Fprintf(&xmlBuf, " <file type=\"%s\">%s</file>\n", f.Type, xmlEscape(f.Path))
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(&xmlBuf, " <file>%s</file>\n", xmlEscape(f.Path))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
xmlBuf.WriteString("</package>\n")
|
||||||
|
}
|
||||||
|
xmlBuf.WriteString("</filelists>\n")
|
||||||
|
|
||||||
|
return gzipBytes(xmlBuf.Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateOtherXMLGZ(metas []provider.RPMMetadata) []byte {
|
||||||
|
var xmlBuf bytes.Buffer
|
||||||
|
xmlBuf.WriteString(xml.Header)
|
||||||
|
fmt.Fprintf(&xmlBuf, "<otherdata xmlns=\"http://linux.duke.edu/metadata/other\" packages=\"%d\">\n", len(metas))
|
||||||
|
|
||||||
|
for _, m := range metas {
|
||||||
|
pkgHash := strings.TrimPrefix(m.ContentHash, "sha256:")
|
||||||
|
fmt.Fprintf(&xmlBuf, "<package pkgid=\"%s\" name=\"%s\" arch=\"%s\">\n", pkgHash, xmlEscape(m.Name), xmlEscape(m.Arch))
|
||||||
|
fmt.Fprintf(&xmlBuf, " <version epoch=\"%d\" ver=\"%s\" rel=\"%s\"/>\n", m.Epoch, xmlEscape(m.Version), xmlEscape(m.Release))
|
||||||
|
for _, cl := range m.Changelogs {
|
||||||
|
fmt.Fprintf(&xmlBuf, " <changelog author=\"%s\" date=\"%d\">%s</changelog>\n",
|
||||||
|
xmlEscape(cl.Author), cl.Date, xmlEscape(cl.Text))
|
||||||
|
}
|
||||||
|
xmlBuf.WriteString("</package>\n")
|
||||||
|
}
|
||||||
|
xmlBuf.WriteString("</otherdata>\n")
|
||||||
|
|
||||||
|
return gzipBytes(xmlBuf.Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeRPMEntry(b *bytes.Buffer, d provider.RPMDep) {
|
||||||
|
if d.Flags != "" {
|
||||||
|
fmt.Fprintf(b, " <rpm:entry name=\"%s\" flags=\"%s\"", xmlEscape(d.Name), d.Flags)
|
||||||
|
if d.Epoch != "" {
|
||||||
|
fmt.Fprintf(b, " epoch=\"%s\"", d.Epoch)
|
||||||
|
}
|
||||||
|
if d.Version != "" {
|
||||||
|
fmt.Fprintf(b, " ver=\"%s\"", xmlEscape(d.Version))
|
||||||
|
}
|
||||||
|
if d.Release != "" {
|
||||||
|
fmt.Fprintf(b, " rel=\"%s\"", xmlEscape(d.Release))
|
||||||
|
}
|
||||||
|
b.WriteString("/>\n")
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(b, " <rpm:entry name=\"%s\"/>\n", xmlEscape(d.Name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func xmlEscape(s string) string {
|
||||||
|
var b bytes.Buffer
|
||||||
|
xml.EscapeText(&b, []byte(s))
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func gzipBytes(data []byte) []byte {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
gz := gzip.NewWriter(&buf)
|
||||||
|
gz.Write(data)
|
||||||
|
gz.Close()
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func sha256Hex(data []byte) string {
|
||||||
|
h := sha256.Sum256(data)
|
||||||
|
return hex.EncodeToString(h[:])
|
||||||
|
}
|
||||||
|
|||||||
+101
-4
@@ -4,10 +4,12 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.unkin.net/unkin/artifactapi/internal/cache"
|
"git.unkin.net/unkin/artifactapi/internal/cache"
|
||||||
@@ -42,7 +44,7 @@ type FetchResult struct {
|
|||||||
Source string // "cache" or "remote"
|
Source string // "cache" or "remote"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) Fetch(ctx context.Context, remote models.Remote, path string, prov provider.Provider) (*FetchResult, error) {
|
func (e *Engine) Fetch(ctx context.Context, remote models.Remote, path string, prov provider.Provider, clientHeaders ...http.Header) (*FetchResult, error) {
|
||||||
classifier := NewClassifier(prov)
|
classifier := NewClassifier(prov)
|
||||||
class := classifier.Classify(remote, path)
|
class := classifier.Classify(remote, path)
|
||||||
|
|
||||||
@@ -103,8 +105,13 @@ func (e *Engine) Fetch(ctx context.Context, remote models.Remote, path string, p
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var fwdHeaders http.Header
|
||||||
|
if len(clientHeaders) > 0 && clientHeaders[0] != nil {
|
||||||
|
fwdHeaders = clientHeaders[0]
|
||||||
|
}
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
result, err := e.fetchFromUpstream(ctx, remote, path, prov, class, ttl)
|
result, err := e.fetchFromUpstream(ctx, remote, path, prov, class, ttl, fwdHeaders)
|
||||||
upstreamMS := int(time.Since(start).Milliseconds())
|
upstreamMS := int(time.Since(start).Milliseconds())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if remote.StaleOnError && isNetworkError(err) {
|
if remote.StaleOnError && isNetworkError(err) {
|
||||||
@@ -124,7 +131,7 @@ func (e *Engine) Fetch(ctx context.Context, remote models.Remote, path string, p
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) fetchFromUpstream(ctx context.Context, remote models.Remote, path string, prov provider.Provider, class Classification, ttl time.Duration) (*FetchResult, error) {
|
func (e *Engine) fetchFromUpstream(ctx context.Context, remote models.Remote, path string, prov provider.Provider, class Classification, ttl time.Duration, clientHeaders http.Header) (*FetchResult, error) {
|
||||||
url := prov.UpstreamURL(remote, path)
|
url := prov.UpstreamURL(remote, path)
|
||||||
|
|
||||||
authHeaders, err := prov.AuthHeaders(ctx, remote)
|
authHeaders, err := prov.AuthHeaders(ctx, remote)
|
||||||
@@ -141,12 +148,37 @@ func (e *Engine) fetchFromUpstream(ctx context.Context, remote models.Remote, pa
|
|||||||
req.Header.Add(k, v)
|
req.Header.Add(k, v)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if clientHeaders != nil {
|
||||||
|
if accept := clientHeaders.Get("Accept"); accept != "" {
|
||||||
|
req.Header.Set("Accept", accept)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := http.DefaultClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, &UpstreamError{Err: err}
|
return nil, &UpstreamError{Err: err}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusUnauthorized {
|
||||||
|
resp.Body.Close()
|
||||||
|
token, err := fetchBearerToken(ctx, resp.Header.Get("Www-Authenticate"), remote)
|
||||||
|
if err == nil && token != "" {
|
||||||
|
req2, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
req2.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
if clientHeaders != nil {
|
||||||
|
if accept := clientHeaders.Get("Accept"); accept != "" {
|
||||||
|
req2.Header.Set("Accept", accept)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resp, err = http.DefaultClient.Do(req2)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &UpstreamError{Err: err}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return nil, &ProxyError{Status: http.StatusUnauthorized, Message: "upstream returned 401"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
return nil, &ProxyError{Status: resp.StatusCode, Message: fmt.Sprintf("upstream returned %d", resp.StatusCode)}
|
return nil, &ProxyError{Status: resp.StatusCode, Message: fmt.Sprintf("upstream returned %d", resp.StatusCode)}
|
||||||
@@ -167,7 +199,7 @@ func (e *Engine) fetchFromUpstream(ctx context.Context, remote models.Remote, pa
|
|||||||
}
|
}
|
||||||
|
|
||||||
contentType := prov.ContentType(path)
|
contentType := prov.ContentType(path)
|
||||||
if ct := resp.Header.Get("Content-Type"); ct != "" && contentType == "application/octet-stream" {
|
if ct := resp.Header.Get("Content-Type"); ct != "" {
|
||||||
contentType = ct
|
contentType = ct
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,6 +351,71 @@ func (r readerAt) ReadAt(p []byte, off int64) (n int, err error) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func fetchBearerToken(ctx context.Context, wwwAuth string, remote models.Remote) (string, error) {
|
||||||
|
if !strings.HasPrefix(wwwAuth, "Bearer ") {
|
||||||
|
return "", fmt.Errorf("not a Bearer challenge")
|
||||||
|
}
|
||||||
|
|
||||||
|
params := map[string]string{}
|
||||||
|
for _, part := range strings.Split(wwwAuth[7:], ",") {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
eq := strings.Index(part, "=")
|
||||||
|
if eq < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := part[:eq]
|
||||||
|
val := strings.Trim(part[eq+1:], `"`)
|
||||||
|
params[key] = val
|
||||||
|
}
|
||||||
|
|
||||||
|
realm := params["realm"]
|
||||||
|
if realm == "" {
|
||||||
|
return "", fmt.Errorf("no realm in Bearer challenge")
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenURL := realm
|
||||||
|
sep := "?"
|
||||||
|
if s, ok := params["service"]; ok {
|
||||||
|
tokenURL += sep + "service=" + s
|
||||||
|
sep = "&"
|
||||||
|
}
|
||||||
|
if s, ok := params["scope"]; ok {
|
||||||
|
tokenURL += sep + "scope=" + s
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, tokenURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if remote.Username != "" && remote.Password != "" {
|
||||||
|
req.SetBasicAuth(remote.Username, remote.Password)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("token endpoint returned %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokenResp struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if tokenResp.Token != "" {
|
||||||
|
return tokenResp.Token, nil
|
||||||
|
}
|
||||||
|
return tokenResp.AccessToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
type ProxyError struct {
|
type ProxyError struct {
|
||||||
Status int
|
Status int
|
||||||
Message string
|
Message string
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import (
|
|||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
|
version string
|
||||||
router chi.Router
|
router chi.Router
|
||||||
db *database.DB
|
db *database.DB
|
||||||
cache *cache.Redis
|
cache *cache.Redis
|
||||||
@@ -45,7 +46,7 @@ type Server struct {
|
|||||||
gc *gc.Collector
|
gc *gc.Collector
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg *config.Config) (*Server, error) {
|
func New(cfg *config.Config, version string) (*Server, error) {
|
||||||
db, err := database.New(cfg.DatabaseDSN())
|
db, err := database.New(cfg.DatabaseDSN())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("database: %w", err)
|
return nil, fmt.Errorf("database: %w", err)
|
||||||
@@ -68,6 +69,7 @@ func New(cfg *config.Config) (*Server, error) {
|
|||||||
|
|
||||||
s := &Server{
|
s := &Server{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
|
version: version,
|
||||||
db: db,
|
db: db,
|
||||||
cache: redis,
|
cache: redis,
|
||||||
store: s3,
|
store: s3,
|
||||||
@@ -96,6 +98,7 @@ func (s *Server) routes() chi.Router {
|
|||||||
|
|
||||||
proxyHandler := v1.NewProxyHandler(s.engine, s.virtEngine, s.db, s.store, s.localHandler)
|
proxyHandler := v1.NewProxyHandler(s.engine, s.virtEngine, s.db, s.store, s.localHandler)
|
||||||
r.Mount("/api/v1", proxyHandler.Routes())
|
r.Mount("/api/v1", proxyHandler.Routes())
|
||||||
|
r.Mount("/v2", proxyHandler.DockerV2Routes())
|
||||||
|
|
||||||
remotesHandler := v2.NewRemotesHandler(s.db)
|
remotesHandler := v2.NewRemotesHandler(s.db)
|
||||||
virtualsHandler := v2.NewVirtualsHandler(s.db)
|
virtualsHandler := v2.NewVirtualsHandler(s.db)
|
||||||
@@ -137,7 +140,7 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
fmt.Fprint(w, `{"name":"artifactapi","version":"3.0.0-dev"}`)
|
fmt.Fprintf(w, `{"name":"artifactapi","version":"%s"}`, s.version)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) newHTTPServer() *http.Server {
|
func (s *Server) newHTTPServer() *http.Server {
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ func (e *Engine) fetchMemberIndexes(ctx context.Context, virt models.Virtual, pa
|
|||||||
results[idx] = result{err: fmt.Errorf("local index %q: %w", name, err)}
|
results[idx] = result{err: fmt.Errorf("local index %q: %w", name, err)}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
results[idx] = result{index: MemberIndex{RemoteName: name, RepoType: remote.RepoType, Body: body}}
|
results[idx] = result{index: MemberIndex{RemoteName: name, RepoType: remote.RepoType, BaseURL: remote.BaseURL, Body: body}}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,7 +102,7 @@ func (e *Engine) fetchMemberIndexes(ctx context.Context, virt models.Virtual, pa
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
results[idx] = result{index: MemberIndex{RemoteName: name, RepoType: remote.RepoType, Body: body}}
|
results[idx] = result{index: MemberIndex{RemoteName: name, RepoType: remote.RepoType, BaseURL: remote.BaseURL, Body: body}}
|
||||||
}(i, memberName)
|
}(i, memberName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,15 +54,27 @@ func (m *HelmMerger) MergeIndexes(members []MemberIndex, proxyBaseURL string) ([
|
|||||||
seen[chart][ver.Version] = true
|
seen[chart][ver.Version] = true
|
||||||
|
|
||||||
if proxyBaseURL != "" {
|
if proxyBaseURL != "" {
|
||||||
|
routePrefix := "remote"
|
||||||
|
if member.RepoType == "local" {
|
||||||
|
routePrefix = "local"
|
||||||
|
}
|
||||||
|
baseHost := extractHost(member.BaseURL)
|
||||||
|
|
||||||
for i, u := range ver.URLs {
|
for i, u := range ver.URLs {
|
||||||
if strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://") {
|
if strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://") {
|
||||||
ver.URLs[i] = fmt.Sprintf("%s/api/v1/remote/%s/%s",
|
if baseHost != "" && extractHost(u) != baseHost {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
relPath := extractPathRelativeToBase(u, member.BaseURL)
|
||||||
|
ver.URLs[i] = fmt.Sprintf("%s/api/v1/%s/%s/%s",
|
||||||
strings.TrimRight(proxyBaseURL, "/"),
|
strings.TrimRight(proxyBaseURL, "/"),
|
||||||
|
routePrefix,
|
||||||
member.RemoteName,
|
member.RemoteName,
|
||||||
extractPath(u))
|
relPath)
|
||||||
} else {
|
} else {
|
||||||
ver.URLs[i] = fmt.Sprintf("%s/api/v1/remote/%s/%s",
|
ver.URLs[i] = fmt.Sprintf("%s/api/v1/%s/%s/%s",
|
||||||
strings.TrimRight(proxyBaseURL, "/"),
|
strings.TrimRight(proxyBaseURL, "/"),
|
||||||
|
routePrefix,
|
||||||
member.RemoteName,
|
member.RemoteName,
|
||||||
u)
|
u)
|
||||||
}
|
}
|
||||||
@@ -78,6 +90,31 @@ func (m *HelmMerger) MergeIndexes(members []MemberIndex, proxyBaseURL string) ([
|
|||||||
return yaml.Marshal(merged)
|
return yaml.Marshal(merged)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func extractHost(rawURL string) string {
|
||||||
|
idx := strings.Index(rawURL, "://")
|
||||||
|
if idx == -1 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
rest := rawURL[idx+3:]
|
||||||
|
slashIdx := strings.Index(rest, "/")
|
||||||
|
if slashIdx == -1 {
|
||||||
|
return rest
|
||||||
|
}
|
||||||
|
return rest[:slashIdx]
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractPathRelativeToBase(rawURL, baseURL string) string {
|
||||||
|
fullPath := extractPath(rawURL)
|
||||||
|
basePath := extractPath(baseURL)
|
||||||
|
if basePath != "" {
|
||||||
|
basePath = strings.TrimRight(basePath, "/") + "/"
|
||||||
|
if strings.HasPrefix(fullPath, basePath) {
|
||||||
|
return fullPath[len(basePath):]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fullPath
|
||||||
|
}
|
||||||
|
|
||||||
func extractPath(rawURL string) string {
|
func extractPath(rawURL string) string {
|
||||||
idx := strings.Index(rawURL, "://")
|
idx := strings.Index(rawURL, "://")
|
||||||
if idx == -1 {
|
if idx == -1 {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
type MemberIndex struct {
|
type MemberIndex struct {
|
||||||
RemoteName string
|
RemoteName string
|
||||||
RepoType models.RepoType
|
RepoType models.RepoType
|
||||||
|
BaseURL string
|
||||||
Body []byte
|
Body []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,13 +6,20 @@ COPY package.json package-lock.json* ./
|
|||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
ARG BASE_PATH=/
|
||||||
|
ENV BASE_PATH=${BASE_PATH}
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM nginx:alpine
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
ARG BASE_PATH=/
|
||||||
|
|
||||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
RUN sed -i "s|\${BASE_PATH}|${BASE_PATH}|g" /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
|||||||
+6
-27
@@ -5,33 +5,12 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
location /api/ {
|
location ${BASE_PATH}/ {
|
||||||
proxy_pass http://artifactapi:8000;
|
rewrite ^${BASE_PATH}(/.*)$ $1 break;
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_buffering off;
|
|
||||||
}
|
|
||||||
|
|
||||||
location /v2/ {
|
|
||||||
proxy_pass http://artifactapi:8000;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_buffering off;
|
|
||||||
}
|
|
||||||
|
|
||||||
location /health {
|
|
||||||
proxy_pass http://artifactapi:8000;
|
|
||||||
}
|
|
||||||
|
|
||||||
location /metrics {
|
|
||||||
proxy_pass http://artifactapi:8000;
|
|
||||||
}
|
|
||||||
|
|
||||||
location / {
|
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location = ${BASE_PATH} {
|
||||||
|
return 301 ${BASE_PATH}/;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { Routes, Route, NavLink } from 'react-router-dom';
|
|||||||
import { Dashboard } from './pages/Dashboard';
|
import { Dashboard } from './pages/Dashboard';
|
||||||
import { Remotes } from './pages/Remotes';
|
import { Remotes } from './pages/Remotes';
|
||||||
import { RemoteDetail } from './pages/RemoteDetail';
|
import { RemoteDetail } from './pages/RemoteDetail';
|
||||||
|
import { Locals } from './pages/Locals';
|
||||||
|
import { LocalDetail } from './pages/LocalDetail';
|
||||||
import { Virtuals } from './pages/Virtuals';
|
import { Virtuals } from './pages/Virtuals';
|
||||||
import { Objects } from './pages/Objects';
|
import { Objects } from './pages/Objects';
|
||||||
import { Probe } from './pages/Probe';
|
import { Probe } from './pages/Probe';
|
||||||
@@ -18,6 +20,7 @@ export function App() {
|
|||||||
<div className="sidebar-nav">
|
<div className="sidebar-nav">
|
||||||
<NavLink to="/" end>Dashboard</NavLink>
|
<NavLink to="/" end>Dashboard</NavLink>
|
||||||
<NavLink to="/remotes">Remotes</NavLink>
|
<NavLink to="/remotes">Remotes</NavLink>
|
||||||
|
<NavLink to="/locals">Locals</NavLink>
|
||||||
<NavLink to="/virtuals">Virtuals</NavLink>
|
<NavLink to="/virtuals">Virtuals</NavLink>
|
||||||
<NavLink to="/probe">Test Remote</NavLink>
|
<NavLink to="/probe">Test Remote</NavLink>
|
||||||
</div>
|
</div>
|
||||||
@@ -31,6 +34,9 @@ export function App() {
|
|||||||
<Route path="/remotes" element={<Remotes />} />
|
<Route path="/remotes" element={<Remotes />} />
|
||||||
<Route path="/remotes/:name" element={<RemoteDetail />} />
|
<Route path="/remotes/:name" element={<RemoteDetail />} />
|
||||||
<Route path="/remotes/:name/objects" element={<Objects />} />
|
<Route path="/remotes/:name/objects" element={<Objects />} />
|
||||||
|
<Route path="/locals" element={<Locals />} />
|
||||||
|
<Route path="/locals/:name" element={<LocalDetail />} />
|
||||||
|
<Route path="/locals/:name/objects" element={<Objects />} />
|
||||||
<Route path="/virtuals" element={<Virtuals />} />
|
<Route path="/virtuals" element={<Virtuals />} />
|
||||||
<Route path="/probe" element={<Probe />} />
|
<Route path="/probe" element={<Probe />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
+5
-1
@@ -4,9 +4,13 @@ import { BrowserRouter } from 'react-router-dom';
|
|||||||
import { App } from './App';
|
import { App } from './App';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
|
declare const __BASE_PATH__: string;
|
||||||
|
|
||||||
|
const basename = __BASE_PATH__.replace(/\/+$/, '') || '/';
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<BrowserRouter>
|
<BrowserRouter basename={basename}>
|
||||||
<App />
|
<App />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
|
|||||||
@@ -50,6 +50,11 @@ export function Dashboard() {
|
|||||||
value={formatNumber(stats.total_blobs_deduped)}
|
value={formatNumber(stats.total_blobs_deduped)}
|
||||||
sub="shared blobs"
|
sub="shared blobs"
|
||||||
/>
|
/>
|
||||||
|
<StatsCard
|
||||||
|
label="Bandwidth Saved"
|
||||||
|
value={formatBytes(stats.bandwidth_saved_30d)}
|
||||||
|
sub="last 30 days"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{health && (
|
{health && (
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useParams, Link } from 'react-router-dom';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import type { Remote } from '../api/types';
|
||||||
|
import { Badge } from '../components/Badge';
|
||||||
|
import './RemoteDetail.css';
|
||||||
|
|
||||||
|
export function LocalDetail() {
|
||||||
|
const { name } = useParams<{ name: string }>();
|
||||||
|
const [remote, setRemote] = useState<Remote | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!name) return;
|
||||||
|
api.getRemote(name)
|
||||||
|
.then(setRemote)
|
||||||
|
.catch(e => setError(e.message));
|
||||||
|
}, [name]);
|
||||||
|
|
||||||
|
if (error) return <div className="error-banner">{error}</div>;
|
||||||
|
if (!remote) return <div className="loading">Loading...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="detail-header">
|
||||||
|
<Link to="/locals" className="back-link">← Locals</Link>
|
||||||
|
<h1 className="page-title">{remote.name}</h1>
|
||||||
|
<div className="detail-badges">
|
||||||
|
<Badge variant="blue">{remote.package_type}</Badge>
|
||||||
|
<Badge variant="default">local</Badge>
|
||||||
|
{remote.managed_by && <Badge variant="green">managed by {remote.managed_by}</Badge>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{remote.description && (
|
||||||
|
<p className="detail-description">{remote.description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="detail-actions">
|
||||||
|
<Link to={`/locals/${remote.name}/objects`} className="btn btn-primary">
|
||||||
|
Browse Files
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import type { Remote } from '../api/types';
|
||||||
|
import { Badge } from '../components/Badge';
|
||||||
|
import { DataTable } from '../components/DataTable';
|
||||||
|
import './Remotes.css';
|
||||||
|
|
||||||
|
const typeColors: Record<string, 'blue' | 'green' | 'yellow' | 'red' | 'default'> = {
|
||||||
|
docker: 'blue',
|
||||||
|
helm: 'green',
|
||||||
|
rpm: 'yellow',
|
||||||
|
pypi: 'blue',
|
||||||
|
npm: 'red',
|
||||||
|
generic: 'default',
|
||||||
|
alpine: 'green',
|
||||||
|
puppet: 'yellow',
|
||||||
|
terraform: 'blue',
|
||||||
|
goproxy: 'green',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Locals() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [remotes, setRemotes] = useState<Remote[]>([]);
|
||||||
|
const [filter, setFilter] = useState('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.listRemotes()
|
||||||
|
.then(r => setRemotes((r || []).filter(x => x.repo_type === 'local')))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filtered = remotes.filter(r => {
|
||||||
|
if (filter && !r.name.toLowerCase().includes(filter.toLowerCase())) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="page-title">Local Repositories</h1>
|
||||||
|
|
||||||
|
<div className="remotes-toolbar">
|
||||||
|
<input
|
||||||
|
className="search-input"
|
||||||
|
placeholder="Filter by name..."
|
||||||
|
value={filter}
|
||||||
|
onChange={e => setFilter(e.target.value)}
|
||||||
|
/>
|
||||||
|
<span className="result-count">{filtered.length} locals</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="loading">Loading...</div>
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
header: 'Name',
|
||||||
|
render: (r: Remote) => <span className="mono">{r.name}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'type',
|
||||||
|
header: 'Type',
|
||||||
|
render: (r: Remote) => (
|
||||||
|
<Badge variant={typeColors[r.package_type] || 'default'}>
|
||||||
|
{r.package_type}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
width: '110px',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'description',
|
||||||
|
header: 'Description',
|
||||||
|
render: (r: Remote) => r.description || <span className="text-muted">—</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'managed',
|
||||||
|
header: 'Managed',
|
||||||
|
render: (r: Remote) =>
|
||||||
|
r.managed_by ? <Badge variant="blue">{r.managed_by}</Badge> : <span className="text-muted">—</span>,
|
||||||
|
width: '100px',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
data={filtered}
|
||||||
|
emptyMessage="No local repositories configured"
|
||||||
|
onRowClick={(r) => navigate(`/locals/${r.name}`)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState, useCallback, useMemo } from 'react';
|
import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||||
import { useParams, Link } from 'react-router-dom';
|
import { useParams, useLocation, Link } from 'react-router-dom';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import type { Artifact } from '../api/types';
|
import type { Artifact } from '../api/types';
|
||||||
import { formatBytes, timeAgo, truncateHash } from '../components/format';
|
import { formatBytes, timeAgo, truncateHash } from '../components/format';
|
||||||
@@ -171,6 +171,9 @@ function TreeRow({ node, depth, expanded, onToggle, onEvict }: TreeRowProps) {
|
|||||||
|
|
||||||
export function Objects() {
|
export function Objects() {
|
||||||
const { name } = useParams<{ name: string }>();
|
const { name } = useParams<{ name: string }>();
|
||||||
|
const location = useLocation();
|
||||||
|
const isLocal = location.pathname.startsWith('/locals/');
|
||||||
|
const backLink = isLocal ? `/locals/${name}` : `/remotes/${name}`;
|
||||||
const [artifacts, setArtifacts] = useState<Artifact[]>([]);
|
const [artifacts, setArtifacts] = useState<Artifact[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [filter, setFilter] = useState('');
|
const [filter, setFilter] = useState('');
|
||||||
@@ -233,7 +236,7 @@ export function Objects() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="detail-header">
|
<div className="detail-header">
|
||||||
<Link to={`/remotes/${name}`} className="back-link">← {name}</Link>
|
<Link to={backLink} className="back-link">← {name}</Link>
|
||||||
<h1 className="page-title">Cached Objects</h1>
|
<h1 className="page-title">Cached Objects</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -32,9 +32,10 @@ export function Remotes() {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const types = [...new Set(remotes.map(r => r.package_type))].sort();
|
const remoteOnly = remotes.filter(r => r.repo_type !== 'local');
|
||||||
|
const types = [...new Set(remoteOnly.map(r => r.package_type))].sort();
|
||||||
|
|
||||||
const filtered = remotes.filter(r => {
|
const filtered = remoteOnly.filter(r => {
|
||||||
if (typeFilter && r.package_type !== typeFilter) return false;
|
if (typeFilter && r.package_type !== typeFilter) return false;
|
||||||
if (filter && !r.name.toLowerCase().includes(filter.toLowerCase())) return false;
|
if (filter && !r.name.toLowerCase().includes(filter.toLowerCase())) return false;
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
+32
-10
@@ -1,21 +1,38 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import type { Virtual } from '../api/types';
|
import type { Remote, Virtual } from '../api/types';
|
||||||
import { Badge } from '../components/Badge';
|
import { Badge } from '../components/Badge';
|
||||||
import { DataTable } from '../components/DataTable';
|
import { DataTable } from '../components/DataTable';
|
||||||
import './Virtuals.css';
|
import './Virtuals.css';
|
||||||
|
|
||||||
export function Virtuals() {
|
export function Virtuals() {
|
||||||
const [virtuals, setVirtuals] = useState<Virtual[]>([]);
|
const [virtuals, setVirtuals] = useState<Virtual[]>([]);
|
||||||
|
const [remoteMap, setRemoteMap] = useState<Record<string, Remote>>({});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [expanded, setExpanded] = useState<string | null>(null);
|
const [expanded, setExpanded] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.listVirtuals()
|
Promise.all([api.listVirtuals(), api.listRemotes()])
|
||||||
.then(v => setVirtuals(v || []))
|
.then(([v, r]) => {
|
||||||
|
setVirtuals(v || []);
|
||||||
|
const map: Record<string, Remote> = {};
|
||||||
|
for (const remote of r || []) {
|
||||||
|
map[remote.name] = remote;
|
||||||
|
}
|
||||||
|
setRemoteMap(map);
|
||||||
|
})
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
function memberLink(name: string) {
|
||||||
|
const remote = remoteMap[name];
|
||||||
|
if (remote?.repo_type === 'local') {
|
||||||
|
return `/locals/${name}`;
|
||||||
|
}
|
||||||
|
return `/remotes/${name}`;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="page-title">Virtual Repositories</h1>
|
<h1 className="page-title">Virtual Repositories</h1>
|
||||||
@@ -40,7 +57,7 @@ export function Virtuals() {
|
|||||||
key: 'members',
|
key: 'members',
|
||||||
header: 'Members',
|
header: 'Members',
|
||||||
render: (v: Virtual) => (
|
render: (v: Virtual) => (
|
||||||
<span className="member-count">{v.members?.length || 0} remotes</span>
|
<span className="member-count">{v.members?.length || 0} repos</span>
|
||||||
),
|
),
|
||||||
width: '110px',
|
width: '110px',
|
||||||
},
|
},
|
||||||
@@ -69,12 +86,17 @@ export function Virtuals() {
|
|||||||
<ul className="member-list">
|
<ul className="member-list">
|
||||||
{virtuals
|
{virtuals
|
||||||
.find(v => v.name === expanded)
|
.find(v => v.name === expanded)
|
||||||
?.members?.map((m, i) => (
|
?.members?.map((m, i) => {
|
||||||
<li key={m}>
|
const remote = remoteMap[m];
|
||||||
<span className="member-priority">{i + 1}</span>
|
const typeLabel = remote?.repo_type === 'local' ? 'local' : 'remote';
|
||||||
<a href={`/remotes/${m}`} className="mono">{m}</a>
|
return (
|
||||||
</li>
|
<li key={m}>
|
||||||
))}
|
<span className="member-priority">{i + 1}</span>
|
||||||
|
<Link to={memberLink(m)} className="mono">{m}</Link>
|
||||||
|
<Badge variant={typeLabel === 'local' ? 'yellow' : 'default'}>{typeLabel}</Badge>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
const basePath = process.env.BASE_PATH || '/'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
base: basePath,
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
@@ -11,4 +14,7 @@ export default defineConfig({
|
|||||||
'/metrics': 'http://localhost:8000',
|
'/metrics': 'http://localhost:8000',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
define: {
|
||||||
|
'__BASE_PATH__': JSON.stringify(basePath),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user