diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 2281254..0000000 --- a/.dockerignore +++ /dev/null @@ -1,15 +0,0 @@ -.git/ -.venv/ -dist/ -tests/ -remotes.yaml -ca-bundle.pem -.env -*.log -docker-compose.yml -.woodpecker/ -.tox/ -.ruff_cache/ -.pytest_cache/ -.pre-commit-cache/ -minio_data/ diff --git a/.gitignore b/.gitignore index 83b008d..897030b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,61 +1,2 @@ -# Python -__pycache__/ -*.py[cod] -*$py.class -*.so -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# Virtual environment -.venv/ -venv/ -ENV/ -env/ - -# IDE -.vscode/ -.idea/ -*.swp -*.swo - -# Environment variables -.env - -# Logs -*.log - -# uv -uv.lock - -# tox -.tox/ - -# pytest -.pytest_cache/ - -# pre-commit -.pre-commit-cache/ - -# ruff -.ruff_cache/ - -# Docker volumes -minio_data/ - -# Local configuration overrides -ca-bundle.pem +bin/ +terraform/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml deleted file mode 100644 index 459d659..0000000 --- a/.pre-commit-config.yaml +++ /dev/null @@ -1,7 +0,0 @@ -repos: - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.12 - hooks: - - id: ruff - args: [--fix, --exit-non-zero-on-fix] - - id: ruff-format diff --git a/.woodpecker/docker.yaml b/.woodpecker/docker.yaml index 985531b..3addb4e 100644 --- a/.woodpecker/docker.yaml +++ b/.woodpecker/docker.yaml @@ -3,7 +3,7 @@ when: ref: refs/tags/v* steps: - - name: docker + - name: docker-api image: woodpeckerci/plugin-docker-buildx settings: registry: git.unkin.net @@ -14,5 +14,17 @@ steps: tags: - ${CI_COMMIT_TAG} - latest - build_args: - - VERSION=${CI_COMMIT_TAG##v} + + - name: docker-web + image: woodpeckerci/plugin-docker-buildx + settings: + registry: git.unkin.net + repo: git.unkin.net/unkin/artifactapi-ui + dockerfile: ui/Dockerfile.ui + context: ui + username: droneci + password: + from_secret: DRONECI_PASSWORD + tags: + - ${CI_COMMIT_TAG} + - latest diff --git a/.woodpecker/pre-commit.yaml b/.woodpecker/pre-commit.yaml index 5086dd5..2dd88b8 100644 --- a/.woodpecker/pre-commit.yaml +++ b/.woodpecker/pre-commit.yaml @@ -3,7 +3,7 @@ when: steps: - name: pre-commit - image: git.unkin.net/unkin/almalinux9-base:20260606 + image: golang:1.25 commands: - - uvx pre-commit run --all-files - + - test -z "$(gofmt -l .)" + - go vet ./... diff --git a/.woodpecker/test.yaml b/.woodpecker/test.yaml index b137cd2..7bffc3e 100644 --- a/.woodpecker/test.yaml +++ b/.woodpecker/test.yaml @@ -3,6 +3,6 @@ when: steps: - name: test - image: git.unkin.net/unkin/almalinux9-base:20260606 + image: golang:1.25 commands: - - uvx --python 3.11 --with tox-uv tox + - go test -race -count=1 ./pkg/... ./internal/... diff --git a/Dockerfile b/Dockerfile index 7350c96..1bd2552 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,23 +1,20 @@ -FROM git.unkin.net/unkin/almalinux9-base:latest +FROM golang:1.25-alpine AS builder -ARG VERSION=0.0.0.dev0 +RUN apk add --no-cache git -COPY . /build +WORKDIR /build -RUN HATCH_VCS_PRETEND_VERSION=${VERSION} \ - SETUPTOOLS_SCM_PRETEND_VERSION=${VERSION} \ - uv build --wheel --directory /build && \ - useradd -m -r -s /bin/sh appuser +COPY go.mod go.sum ./ +RUN go mod download -USER appuser -RUN uv tool install --from /build/dist/*.whl artifactapi +COPY . . -USER root -RUN rm -rf /build +RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o artifactapi ./cmd/artifactapi + +FROM gcr.io/distroless/static-debian12:nonroot + +COPY --from=builder /build/artifactapi /usr/local/bin/artifactapi EXPOSE 8000 -HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 CMD curl -f http://localhost:8000/health || exit 1 -USER appuser -ENV PATH="/home/appuser/.local/bin:$PATH" -WORKDIR /app -CMD ["artifactapi"] + +ENTRYPOINT ["artifactapi"] diff --git a/Makefile b/Makefile index 437e11b..df68c20 100644 --- a/Makefile +++ b/Makefile @@ -1,59 +1,49 @@ -.PHONY: build install dev clean test lint format pre-commit tox docker-build docker-up docker-down docker-logs docker-rebuild docker-clean docker-restart +.PHONY: build test lint fmt e2e docker docker-ui compose clean tidy check-go -build: - docker build -t artifactapi:dev . +BINARY := bin/artifactapi +MODULE := git.unkin.net/unkin/artifactapi +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "0.0.0-dev") +GO_VERSION_REQUIRED := 1.23 +GO_VERSION_ACTUAL := $(shell go version | sed 's/go version go\([0-9]*\.[0-9]*\).*/\1/') -install: build +check-go: + @if [ "$$(printf '%s\n%s' "$(GO_VERSION_REQUIRED)" "$(GO_VERSION_ACTUAL)" | sort -V | head -1)" != "$(GO_VERSION_REQUIRED)" ]; then \ + echo "ERROR: Go >= $(GO_VERSION_REQUIRED) required, found $(GO_VERSION_ACTUAL)"; exit 1; \ + fi -docker-build: build +build: check-go tidy + go build -ldflags="-s -w" -o $(BINARY) ./cmd/artifactapi -dev: build - uv sync --dev +test: check-go + go test -race -count=1 ./pkg/... ./internal/... + +lint: check-go + golangci-lint run ./... + go vet ./... + +fmt: check-go + gofmt -w . + goimports -w . + +e2e: check-go + TESTCONTAINERS_RYUK_DISABLED=true go test -tags=e2e -race -count=1 -timeout=5m ./e2e/... + +docker: + docker build -t artifactapi:$(VERSION) . + +docker-ui: + docker build -t artifactapi-ui:$(VERSION) -f ui/Dockerfile.ui ui/ + +compose: + docker compose up -d clean: - rm -rf .venv - rm -rf build/ - rm -rf dist/ - rm -rf *.egg-info/ + rm -rf bin/ -test: - uvx --python 3.11 --with tox-uv tox - -tox: - uvx --python 3.11 --with tox-uv tox - -pre-commit: - uvx --python 3.11 pre-commit run --all-files - -lint: - uv run ruff check --fix . - -format: - uv run ruff format . - -run: - uv run python -m src.artifactapi.main - -docker-up: - docker-compose up --build --force-recreate -d - -docker-down: - docker-compose down - -docker-logs: - docker-compose logs -f - -docker-rebuild: - docker-compose build --no-cache - -docker-clean: - docker-compose down -v --remove-orphans - docker system prune -f - -docker-restart: docker-down docker-up +tidy: + go mod tidy # Bump helpers — reads the latest semver tag and creates the next one. -# If no tag exists yet, starts from v0.0.0. _LATEST := $(shell git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$$' | head -1) _BASE := $(if $(_LATEST),$(_LATEST),v0.0.0) _MAJ := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f1) diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..f7bd97c --- /dev/null +++ b/PLAN.md @@ -0,0 +1,880 @@ +# ArtifactAPI v3 — Go Rewrite Plan + +## Context + +ArtifactAPI is a production artifact proxy/cache serving ~42 remotes (Docker registries, Helm repos, RPM/Alpine repos, GitHub releases, PyPI, npm, Puppet Forge, Terraform registries, Go module proxies) across a Kubernetes cluster. The current Python (FastAPI) implementation works but has architectural debt: opaque hashed S3 paths, no UI for visibility, YAML config files that drift, no garbage collection, no access logging, and virtual repos limited to Helm only. + +The v3 rewrite targets: a single Go binary (API + TUI), a separate React frontend (own Dockerfile), a Terraform provider (separate repo), content-addressable storage, and a cleaner data model that makes the cache inspectable and manageable. + +**Repo**: Same repo (`git.unkin.net/unkin/artifactapi`), new branch. +**Module**: `git.unkin.net/unkin/artifactapi` +**Frontend**: React + Vite, separate Dockerfile, talks to API +**Terraform provider**: Separate repo (`terraform-provider-artifactapi`) + +--- + +## Architecture Overview + +``` +┌───────────────────────────────────┐ ┌──────────────────────┐ +│ Go Binary (API + TUI) │ │ Frontend Container │ +│ │ │ │ +│ ┌──────────┐ ┌───────────────┐ │ │ React + Vite SPA │ +│ │ REST API │ │ Proxy Engine │ │◄───│ nginx / node serve │ +│ │ /api/v2 │ │ /api/v1/... │ │ │ Dockerfile.ui │ +│ │ │ │ /v2/... (OCI) │ │ └──────────────────────┘ +│ └────┬─────┘ └──────┬────────┘ │ +│ │ │ │ ┌──────────────────────┐ +│ ┌────┴───────────────┴────────┐ │ │ Terraform Provider │ +│ │ Data Layer │ │◄───│ (separate repo) │ +│ │ PostgreSQL · Redis · S3 │ │ └──────────────────────┘ +│ └─────────────────────────────┘ │ +│ │ ┌──────────────────────┐ +│ ┌─────────────────────────────┐ │ │ TUI (subcommand) │ +│ │ artifactapi tui │──│───►│ artifactapi tui │ +│ └─────────────────────────────┘ │ │ --endpoint │ +└───────────────────────────────────┘ └──────────────────────┘ +``` + +Three independent deployment units: +1. **Go binary** — API server + TUI subcommand (single `Dockerfile`) +2. **React frontend** — SPA served by nginx (`Dockerfile.ui`), talks to `/api/v2` +3. **Terraform provider** — separate repo, calls `/api/v2` CRUD + +--- + +## Project Structure (Modular) + +``` +artifactapi/ +├── cmd/ +│ └── artifactapi/ +│ └── main.go # entrypoint: serve / tui subcommands +│ +├── pkg/ # PUBLIC — importable by terraform provider, CLI tools +│ ├── models/ # shared domain types +│ │ ├── remote.go # Remote, RemoteConfig, PackageType enum +│ │ ├── virtual.go # Virtual, VirtualConfig +│ │ ├── artifact.go # Artifact, Blob, AccessLogEntry +│ │ ├── local.go # LocalFile, LocalRepo +│ │ └── stats.go # RemoteStats, OverviewStats +│ └── client/ # typed Go API client (used by TUI + Terraform provider) +│ ├── client.go # Client struct, base HTTP +│ ├── remotes.go # remote CRUD methods +│ ├── virtuals.go # virtual CRUD methods +│ ├── objects.go # object browse/evict methods +│ └── stats.go # stats methods +│ +├── internal/ # PRIVATE — server internals +│ ├── server/ +│ │ ├── server.go # HTTP server setup, router +│ │ └── middleware.go # logging, recovery, request-id, access logging +│ │ +│ ├── api/ +│ │ ├── v1/ # proxy endpoints (v1 compat) +│ │ │ ├── proxy.go # GET /api/v1/remote/{name}/{path} +│ │ │ ├── docker.go # /v2/{name}/{path} +│ │ │ ├── virtual.go # GET /api/v1/virtual/{name}/{path} +│ │ │ └── local.go # CRUD /api/v1/local/{name}/{path} +│ │ └── v2/ # management API +│ │ ├── remotes.go # CRUD + stats +│ │ ├── virtuals.go # CRUD +│ │ ├── objects.go # browse/evict cached objects +│ │ ├── stats.go # overview, top-remotes +│ │ ├── events.go # SSE stream +│ │ └── health.go # health, metrics +│ │ +│ ├── provider/ # package-type providers (registry protocol handlers) +│ │ ├── provider.go # Provider interface + registry +│ │ ├── generic/ +│ │ │ ├── generic.go +│ │ │ └── generic_test.go +│ │ ├── docker/ +│ │ │ ├── docker.go # OCI Distribution v2 via go-containerregistry +│ │ │ ├── auth.go # Bearer token fetch + cache +│ │ │ └── docker_test.go +│ │ ├── helm/ +│ │ │ ├── helm.go # index rewriting via helm.sh/helm/v3/pkg/repo +│ │ │ ├── merger.go # virtual index merge +│ │ │ └── helm_test.go +│ │ ├── pypi/ +│ │ │ ├── pypi.go # simple index HTML rewriting +│ │ │ ├── merger.go # virtual simple index merge +│ │ │ └── pypi_test.go +│ │ ├── npm/ +│ │ │ ├── npm.go # metadata JSON rewriting +│ │ │ └── npm_test.go +│ │ ├── rpm/ +│ │ │ ├── rpm.go # repodata patterns +│ │ │ └── rpm_test.go +│ │ ├── alpine/ +│ │ │ ├── alpine.go # APKINDEX patterns +│ │ │ └── alpine_test.go +│ │ ├── puppet/ +│ │ │ ├── puppet.go # file_uri JSON rewriting +│ │ │ └── puppet_test.go +│ │ ├── terraform/ +│ │ │ ├── terraform.go # registry protocol, download URL rewriting +│ │ │ └── terraform_test.go +│ │ └── goproxy/ +│ │ ├── goproxy.go # Go module proxy protocol (GOPROXY) +│ │ └── goproxy_test.go +│ │ +│ ├── proxy/ +│ │ ├── engine.go # core fetch-or-cache logic +│ │ ├── engine_test.go +│ │ ├── classifier.go # immutable vs mutable classification +│ │ ├── classifier_test.go +│ │ ├── revalidator.go # conditional HEAD requests (ETag/Last-Modified) +│ │ └── circuit.go # per-remote circuit breaker +│ │ +│ ├── storage/ +│ │ ├── s3.go # S3 client (minio-go — works with MinIO, Ceph, AWS) +│ │ ├── s3_test.go +│ │ ├── cas.go # content-addressable store logic +│ │ └── cas_test.go +│ │ +│ ├── cache/ +│ │ ├── redis.go # TTL management, fetch locks +│ │ ├── redis_test.go +│ │ └── lock.go # distributed lock abstraction +│ │ +│ ├── database/ +│ │ ├── postgres.go # connection pool, migration runner +│ │ ├── queries/ # SQL query files or sqlc-generated code +│ │ │ ├── remotes.sql.go +│ │ │ ├── virtuals.sql.go +│ │ │ ├── artifacts.sql.go +│ │ │ └── access_log.sql.go +│ │ └── migrations/ # golang-migrate SQL files +│ │ ├── 001_initial.up.sql +│ │ └── 001_initial.down.sql +│ │ +│ ├── metrics/ +│ │ └── prometheus.go # counters, gauges, histograms +│ │ +│ ├── gc/ +│ │ ├── gc.go # background garbage collection goroutine +│ │ └── gc_test.go +│ │ +│ ├── tui/ +│ │ ├── app.go # Bubble Tea main model +│ │ ├── views/ +│ │ │ ├── dashboard.go +│ │ │ ├── remotes.go +│ │ │ ├── objects.go +│ │ │ └── virtuals.go +│ │ └── components/ +│ │ ├── table.go +│ │ └── statusbar.go +│ │ +│ └── config/ +│ └── env.go # environment variable parsing + validation +│ +├── ui/ # React frontend — SEPARATE DOCKERFILE +│ ├── src/ +│ │ ├── App.tsx +│ │ ├── pages/ +│ │ │ ├── Dashboard.tsx +│ │ │ ├── Remotes.tsx +│ │ │ ├── RemoteDetail.tsx +│ │ │ ├── Virtuals.tsx +│ │ │ └── Objects.tsx +│ │ ├── components/ +│ │ │ ├── RemoteTable.tsx +│ │ │ ├── ObjectBrowser.tsx +│ │ │ ├── StatsCard.tsx +│ │ │ └── EventFeed.tsx +│ │ └── api/ +│ │ └── client.ts # typed API client +│ ├── package.json +│ ├── vite.config.ts +│ ├── tsconfig.json +│ ├── Dockerfile.ui # multi-stage: node build → nginx +│ └── nginx.conf # proxy /api/* to backend, serve SPA +│ +├── e2e/ # end-to-end integration tests +│ ├── e2e_test.go # TestMain spins up docker-compose stack +│ ├── proxy_test.go # proxy through real remotes +│ ├── docker_test.go # Docker v2 protocol e2e +│ ├── management_test.go # v2 API CRUD +│ ├── virtual_test.go # virtual repo merge e2e +│ └── docker-compose.e2e.yml # postgres + redis + minio for tests +│ +├── go.mod +├── go.sum +├── Makefile +├── Dockerfile # Go binary (API server + TUI) +├── Dockerfile.ui # symlink or copy → ui/Dockerfile.ui +└── docker-compose.yml +``` + +### Key Modularisation Decisions + +- **`pkg/models/`** — Shared domain types importable by the Terraform provider and any external tooling. No dependencies on internal packages +- **`pkg/client/`** — Typed Go API client used by both the TUI and the Terraform provider. Depends only on `pkg/models/` and stdlib +- **`internal/provider/`** — Each package type is its own subpackage with isolated tests. A provider registry maps `PackageType → Provider` +- **`internal/database/queries/`** — Use [sqlc](https://sqlc.dev/) to generate type-safe query functions from SQL, or hand-written query files +- **`e2e/`** — Separate test binary that spins up a real docker-compose stack + +--- + +## Go Ecosystem Libraries + +Prefer existing, maintained Go modules over writing protocol handlers from scratch. + +### Package-Type Libraries + +| Package Type | Go Module | What It Gives Us | +|---|---|---| +| **Docker/OCI** | `github.com/google/go-containerregistry` | Full Registry v2/OCI client: manifest parsing, auth challenges, blob operations. `pkg/registry` can implement a v2 server. Reference: `github.com/regclient/regclient` | +| **Helm** | `helm.sh/helm/v3/pkg/repo` | Parse/generate `index.yaml`, `IndexFile`/`ChartVersion` types, URL entries. Used directly for merge | +| **Terraform** | `github.com/hashicorp/terraform-registry-address` | Provider/module address parsing, `ForRegistryProtocol()` URL generation. Protocol spec: provider registry protocol v1 | +| **Go Modules** | `github.com/goproxy/goproxy` | Minimalist GOPROXY protocol handler, implements full spec as `http.Handler`. Handles `/@v/list`, `/@v/{v}.info`, `/@v/{v}.mod`, `/@v/{v}.zip`, `/@latest` | +| **RPM** | `rs3.io/go/rpm/repomd` | Parse `repomd.xml`, `primary.xml` with proper XML namespace handling | +| **Alpine** | `gitlab.alpinelinux.org/alpine/go` | Official Alpine library: parse APKINDEX, `.apk` files | +| **PyPI** | stdlib `golang.org/x/net/html` | No dedicated Go PyPI library exists. Parse simple index HTML with `x/net/html`, extract `` tags. Minimal — the rewriting is just href replacement | +| **npm** | stdlib `encoding/json` | npm metadata is JSON — parse with stdlib, rewrite `dist.tarball` URLs. No special library needed | +| **Puppet Forge** | stdlib `encoding/json` | Forge API is JSON — parse and rewrite `file_uri` fields. Community lib `github.com/johnmccabe/go-puppetforge` exists but is thin; stdlib suffices | + +### Infrastructure Libraries + +| Purpose | Go Module | Why This One | +|---|---|---| +| **HTTP router** | `github.com/go-chi/chi/v5` | Lightweight, stdlib `http.Handler` compatible, middleware chain | +| **PostgreSQL** | `github.com/jackc/pgx/v5` | Pure Go, connection pooling, COPY support, prepared statements | +| **SQL generation** | `github.com/sqlc-dev/sqlc` | Generate type-safe Go from SQL queries — no ORM, no reflection | +| **Redis** | `github.com/redis/go-redis/v9` | Full Redis client, pipelining, pub/sub | +| **S3 (MinIO/Ceph/AWS)** | `github.com/minio/minio-go/v7` | Native S3-compatible client. Works with MinIO, Ceph RGW, AWS S3, any S3-compatible backend out of the box. Lighter than aws-sdk-go-v2, purpose-built for S3 compat | +| **DB migrations** | `github.com/golang-migrate/migrate/v4` | SQL file-based migrations, CLI + library | +| **Prometheus** | `github.com/prometheus/client_golang` | Counters, gauges, histograms | +| **TUI** | `github.com/charmbracelet/bubbletea` | Elm-architecture TUI framework | +| **TUI styling** | `github.com/charmbracelet/lipgloss` | Terminal styling | +| **TUI components** | `github.com/charmbracelet/bubbles` | Table, text input, spinner, etc. | +| **Structured logging** | `log/slog` (stdlib) | Go 1.21+ structured logging, zero dependencies | +| **Testing** | `github.com/stretchr/testify` | Assertions + require for unit tests | +| **Test containers** | `github.com/testcontainers/testcontainers-go` | Spin up Postgres/Redis/MinIO in e2e tests | + +### S3 Client: Multi-Backend Support + +Using `minio-go/v7` as the S3 client because it natively supports: +- **MinIO** — primary development/production target +- **Ceph RGW** — S3-compatible via endpoint config +- **AWS S3** — via region + credential config +- **Any S3-compatible** — GCS (interop mode), Wasabi, DigitalOcean Spaces, etc. + +No abstraction layer needed — `minio-go` handles endpoint differences internally. Config: +```go +client, _ := minio.New(endpoint, &minio.Options{ + Creds: credentials.NewStaticV4(accessKey, secretKey, ""), + Secure: useTLS, + Region: region, // optional, for AWS +}) +``` + +--- + +## Data Layer + +### PostgreSQL Schema + +```sql +-- Remotes: managed exclusively by Terraform +CREATE TABLE remotes ( + name TEXT PRIMARY KEY, + package_type TEXT NOT NULL, -- generic, docker, helm, pypi, npm, rpm, alpine, puppet, terraform, goproxy + base_url TEXT NOT NULL, + description TEXT DEFAULT '', + username TEXT DEFAULT '', + password TEXT DEFAULT '', + immutable_ttl INTEGER DEFAULT 0, + mutable_ttl INTEGER DEFAULT 3600, + check_mutable BOOLEAN DEFAULT TRUE, + immutable_patterns TEXT[] DEFAULT '{}', -- user-defined immutable patterns + mutable_patterns TEXT[] DEFAULT '{}', -- user-defined mutable patterns (merged with provider built-ins) + allowlist TEXT[] DEFAULT '{}', -- if empty, allow all paths; if non-empty, only matching paths proxied + blocklist TEXT[] DEFAULT '{}', -- always denied, checked before allowlist + ban_tags_enabled BOOLEAN DEFAULT FALSE, + ban_tags TEXT[] DEFAULT '{}', + quarantine_enabled BOOLEAN DEFAULT FALSE, + quarantine_days INTEGER DEFAULT 3, + stale_on_error BOOLEAN DEFAULT TRUE, + releases_remote TEXT DEFAULT '', -- terraform type: name of CDN remote for download URL rewriting + managed_by TEXT DEFAULT '', -- 'terraform' or empty + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Virtual repositories +CREATE TABLE virtuals ( + name TEXT PRIMARY KEY, + package_type TEXT NOT NULL, + description TEXT DEFAULT '', + members TEXT[] NOT NULL, + managed_by TEXT DEFAULT '', + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Content-addressable blob storage tracking +CREATE TABLE blobs ( + content_hash TEXT PRIMARY KEY, + s3_key TEXT NOT NULL, + size_bytes BIGINT NOT NULL, + content_type TEXT DEFAULT 'application/octet-stream', + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Artifact metadata: maps (remote, path) → content blob +CREATE TABLE artifacts ( + id BIGSERIAL PRIMARY KEY, + remote_name TEXT NOT NULL REFERENCES remotes(name) ON DELETE CASCADE, + path TEXT NOT NULL, + content_hash TEXT NOT NULL REFERENCES blobs(content_hash), + upstream_etag TEXT DEFAULT '', + upstream_last_modified TIMESTAMPTZ, + first_seen_at TIMESTAMPTZ DEFAULT NOW(), + last_fetched_at TIMESTAMPTZ DEFAULT NOW(), + last_accessed_at TIMESTAMPTZ DEFAULT NOW(), + fetch_count BIGINT DEFAULT 1, + access_count BIGINT DEFAULT 1, + UNIQUE(remote_name, path) +); + +CREATE INDEX idx_artifacts_remote ON artifacts(remote_name); +CREATE INDEX idx_artifacts_last_accessed ON artifacts(last_accessed_at); + +-- Local file uploads +CREATE TABLE local_files ( + id BIGSERIAL PRIMARY KEY, + repo_name TEXT NOT NULL, + file_path TEXT NOT NULL, + content_hash TEXT NOT NULL REFERENCES blobs(content_hash), + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(repo_name, file_path) +); + +-- Access log (append-only, powers dashboards) +CREATE TABLE access_log ( + id BIGSERIAL PRIMARY KEY, + remote_name TEXT NOT NULL, + path TEXT NOT NULL, + cache_hit BOOLEAN NOT NULL, + size_bytes BIGINT DEFAULT 0, + upstream_ms INTEGER DEFAULT 0, + client_ip TEXT DEFAULT '', + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX idx_access_log_remote_time ON access_log(remote_name, created_at); +``` + +### Redis Usage (Ephemeral Only) + +| Key pattern | Type | TTL | Purpose | +|---|---|---|---| +| `ttl:{remote}:{path}` | STRING | remote's immutable/mutable TTL | Artifact freshness — existence = still fresh | +| `lock:{remote}:{path}` | STRING (NX) | 30s | Fetch lock — prevents thundering herd | +| `etag:{remote}:{path}` | STRING | same as TTL key | Cached ETag for conditional revalidation | +| `circuit:{remote}` | STRING | configurable | Circuit breaker — consecutive failure count | + +Losing Redis = all TTLs expire = next request re-validates upstream. No data loss. + +### S3 Layout (Content-Addressable) + +``` +artifacts-bucket/ +├── blobs/sha256/{content_hash} # immutable CAS blobs +├── indexes/{remote}/{path} # mutable index files (helm, pypi, rpm, etc.) +├── indexes/{virtual}/{path} # merged virtual indexes +└── local/{repo}/{path} # user uploads (CAS-backed via blobs table) +``` + +--- + +## Terraform Remote Type (New in v2) + +The `terraform` package type proxies the Terraform Provider Registry Protocol: + +- **URL construction**: prepends `/v1/providers/` to request paths +- **Built-in mutable pattern**: `[^/]+/[^/]+/versions$` (version listings change over time) +- **Built-in immutable pattern**: `[^/]+/[^/]+/[^/]+/download/[^/]+/[^/]+$` (per-version download info is fixed) +- **Response rewriting**: download info JSON — rewrites `download_url`, `shasums_url`, `shasums_signature_url` to route through a companion `releases_remote` (e.g., `hashicorp-releases` generic remote) +- **Config**: requires `releases_remote` field pointing to the CDN remote that serves the actual binaries + +Uses `github.com/hashicorp/terraform-registry-address` for address parsing and protocol-compliant URL generation. + +--- + +## Go Module Proxy Remote Type (New) + +The `goproxy` package type implements the GOPROXY protocol (Go module proxy): + +| Endpoint | Mutability | Description | +|---|---|---| +| `{module}/@v/list` | Mutable | Plain text list of known versions | +| `{module}/@latest` | Mutable | JSON metadata for latest version | +| `{module}/@v/{version}.info` | Immutable | JSON version metadata (`Version`, `Time`) | +| `{module}/@v/{version}.mod` | Immutable | `go.mod` file for that version | +| `{module}/@v/{version}.zip` | Immutable | Source archive for that version | + +- **No URL rewriting needed** — responses are self-contained (no embedded URLs) +- **Config**: `base_url` points to upstream proxy (e.g., `https://proxy.golang.org`) +- **Client usage**: set `GOPROXY=https://artifactapi.example.com/api/v1/remote/goproxy` +- Uses `github.com/goproxy/goproxy` for protocol handling + +--- + +## Allowlist / Blocklist / Automatic Mutable Patterns + +### Access Control (Per-Remote) + +| Field | Default | Behavior | +|---|---|---| +| `blocklist` | `[]` (empty) | If a path matches any blocklist pattern → **403 Forbidden**. Checked first | +| `allowlist` | `[]` (empty) | If empty → **allow everything**. If non-empty → only matching paths are proxied; everything else → **403** | + +Evaluation order: blocklist → allowlist → proxy. No allowlist + no blocklist = open proxy (default). + +### Automatic Mutable Patterns (Per-Provider Built-ins) + +Each provider declares built-in mutable patterns that are **always merged** with user-defined `mutable_patterns`. Users never need to configure these — the provider knows which paths change over time. + +| Provider | Built-in Mutable Patterns | Rationale | +|---|---|---| +| **generic** | *(none)* | No convention for what's mutable | +| **docker** | `/manifests/(?!sha256:)[^/]+$`, `/tags/list$` | Tag manifests change; digest manifests don't | +| **helm** | `index\.yaml$` | Chart index changes when new charts are published | +| **pypi** | `simple/` | Package index pages change with new releases | +| **npm** | `^[^/]+$` (package metadata, not `.tgz`) | Package metadata changes; tarballs are immutable | +| **rpm** | `repomd\.xml$`, `repodata/.*`, `Packages\.gz$` | Repo metadata rebuilt on every publish | +| **alpine** | `APKINDEX\.tar\.gz$` | Package index rebuilt on every publish | +| **puppet** | `^v3/modules/`, `^v3/releases` | Module metadata changes with new releases | +| **terraform** | `[^/]+/[^/]+/versions$` | Provider version listings grow over time | +| **goproxy** | `@v/list$`, `@latest$` | Version list and latest pointer change | + +These are returned by `Provider.BuiltinMutablePatterns()` and merged at classification time: +``` +effective_mutable = provider.BuiltinMutablePatterns() ∪ remote.mutable_patterns +``` + +If a path matches `effective_mutable` → use `mutable_ttl`. If it matches `remote.immutable_patterns` → use `immutable_ttl`. Immutable patterns take precedence over mutable when both match. + +--- + +## API Design + +### v1 Proxy Endpoints (Backwards Compatible) + +| Method | Path | Description | +|---|---|---| +| `GET` | `/api/v1/remote/{name}/{path}` | Proxy/cache artifact | +| `GET` | `/api/v1/virtual/{name}/{path}` | Virtual repo proxy | +| `GET/HEAD` | `/v2/{name}/{path}` | Docker Registry v2 | +| `GET` | `/v2/` | Docker v2 ping | +| `GET/PUT/HEAD/DELETE` | `/api/v1/local/{name}/{path}` | Local repo CRUD | + +### v2 Management API (New) + +``` +GET /api/v2/remotes → [{name, package_type, base_url, description, stats}] +GET /api/v2/remotes/{name} → {full config + stats + health} +POST /api/v2/remotes → create remote (Terraform provider) +PUT /api/v2/remotes/{name} → update remote (Terraform provider) +DELETE /api/v2/remotes/{name} → delete remote — cascades artifacts, GC cleans S3 + +GET /api/v2/virtuals → [{name, package_type, members, stats}] +GET /api/v2/virtuals/{name} → {full config + member details} +POST /api/v2/virtuals → create virtual +PUT /api/v2/virtuals/{name} → update virtual +DELETE /api/v2/virtuals/{name} → delete virtual + +GET /api/v2/remotes/{name}/objects → paginated objects + ?q=pattern&sort=size|accessed|age&page=1&per_page=50 +DELETE /api/v2/remotes/{name}/objects/{path} → evict specific cached object +DELETE /api/v2/remotes/{name}/cache → flush cache + ?type=all|indexes|blobs + +GET /api/v2/stats → overview stats +GET /api/v2/stats/top-remotes → top remotes by size/requests/hit-rate + +GET /api/v2/health → {status, postgres, redis, s3, uptime} +GET /metrics → Prometheus format +GET /api/v2/events → SSE stream +``` + +--- + +## Proxy Engine + +### Request Flow + +``` +Client Request + │ + ▼ +Classify (immutable/mutable/denied) + │ + ├── blocklist match → 403 + ├── allowlist non-empty + no match → 403 + │ + ▼ +Check Redis TTL key + │ + ├── exists (fresh) → serve from S3, log access + │ + ├── missing (expired or uncached) + │ │ + │ ▼ + │ Acquire fetch lock (Redis SETNX, 30s TTL) + │ │ + │ ├── lock acquired + │ │ ├── mutable + check_mutable + have ETag → HEAD upstream + │ │ │ ├── 304 → refresh TTL, serve from S3 + │ │ │ └── changed → full fetch + │ │ └── full fetch from upstream + │ │ → provider.RewriteResponse() if needed + │ │ → CAS store (hash → check blobs → upload if new) + │ │ → upsert artifact in Postgres + │ │ → set Redis TTL + release lock + │ │ → on upstream error + stale_on_error → refresh TTL, serve stale + │ │ + │ └── lock not acquired → poll S3 briefly, serve if another pod fetched it + │ + ▼ +Stream response from S3, log access +``` + +### Circuit Breaker + +Per-remote, tracked in Redis. Closed → Open (after N failures) → Half-open (after cooldown). Exposed via `GET /api/v2/remotes/{name}` health field. + +### Content-Addressable Storage + +1. Stream upstream → temp file, compute SHA256 inline +2. Check `blobs` table for hash +3. Exists → skip S3 upload, upsert `artifacts` row only +4. New → upload to `blobs/sha256/{hash}`, insert both rows + +### Garbage Collection + +Background goroutine (configurable interval, default 1h): +1. Orphaned blobs: delete S3 objects whose `content_hash` has no referencing `artifacts` or `local_files` rows +2. Cold artifacts: optional per-remote, delete artifacts not accessed in N days +3. Remote deletion: `ON DELETE CASCADE` handles Postgres; GC sweeps orphaned blobs + +--- + +## Package Providers + +### Provider Interface + +```go +type Provider interface { + Type() models.PackageType + BuiltinMutablePatterns() []*regexp.Regexp + BuiltinImmutablePatterns() []*regexp.Regexp + ContentType(path string) string + UpstreamURL(remote models.Remote, path string) string + RewriteResponse(body []byte, remote models.Remote, proxyBaseURL string) ([]byte, error) + AuthHeaders(ctx context.Context, remote models.Remote) (http.Header, error) +} + +type IndexMerger interface { + MergeIndexes(members []MemberIndex, proxyBaseURL string) ([]byte, error) +} +``` + +### Provider Registry + +```go +var registry = map[models.PackageType]Provider{ + models.PackageGeneric: &generic.Provider{}, + models.PackageDocker: &docker.Provider{}, + models.PackageHelm: &helm.Provider{}, + models.PackagePyPI: &pypi.Provider{}, + models.PackageNPM: &npm.Provider{}, + models.PackageRPM: &rpm.Provider{}, + models.PackageAlpine: &alpine.Provider{}, + models.PackagePuppet: &puppet.Provider{}, + models.PackageTerraform: &terraform.Provider{}, + models.PackageGoProxy: &goproxy.Provider{}, +} + +func Get(t models.PackageType) (Provider, error) { ... } +``` + +Each provider lives in its own subpackage under `internal/provider/` with its own `_test.go`. + +--- + +## Testing Strategy + +### Unit Tests + +Every package gets `_test.go` files alongside the source. Run with `go test ./...`. + +| Package | What's Tested | +|---|---| +| `internal/provider/docker/` | Auth token parsing/caching, manifest classification, tag banning, URL construction, blob key generation | +| `internal/provider/helm/` | `index.yaml` parsing (using `helm.sh/helm/v3/pkg/repo`), URL rewriting, index merging | +| `internal/provider/pypi/` | Simple index HTML parsing, URL rewriting, index merging | +| `internal/provider/npm/` | Metadata JSON rewriting (`dist.tarball` URLs) | +| `internal/provider/terraform/` | Registry URL construction, download info JSON rewriting, `releases_remote` URL extraction | +| `internal/provider/rpm/` | Mutable pattern matching (repodata) | +| `internal/provider/alpine/` | Mutable pattern matching (APKINDEX) | +| `internal/provider/puppet/` | `file_uri` JSON rewriting | +| `internal/proxy/` | Classifier (immutable vs mutable vs denied), circuit breaker state transitions, revalidator logic | +| `internal/storage/` | CAS key generation, dedup detection, S3 operation mocking | +| `internal/cache/` | Redis TTL set/check, fetch lock acquire/release/contention | +| `internal/gc/` | Orphan detection queries, cold artifact selection | +| `pkg/models/` | Model validation, PackageType enum | +| `pkg/client/` | API client request/response serialization | + +### End-to-End Tests + +Located in `e2e/`. Use `testcontainers-go` to spin up real Postgres, Redis, and MinIO containers. The test binary starts the actual `artifactapi` server against these backends. + +```go +// e2e/e2e_test.go +func TestMain(m *testing.M) { + // Start postgres, redis, minio via testcontainers-go + // Run migrations + // Start artifactapi server on random port + // Run tests + // Tear down +} +``` + +| Test File | What's Tested | +|---|---| +| `e2e/proxy_test.go` | Proxy a real GitHub release through generic remote, verify S3 storage, verify Redis TTL, verify Postgres artifact row, verify cache hit on second request | +| `e2e/docker_test.go` | Pull a real image manifest + blob through Docker v2 proxy, verify blob deduplication, tag banning | +| `e2e/management_test.go` | Full CRUD lifecycle: create remote via v2 API, proxy through it, list objects, evict object, flush cache, delete remote | +| `e2e/virtual_test.go` | Create two helm remotes + virtual, fetch merged index, verify priority ordering | +| `e2e/terraform_test.go` | Proxy terraform provider version listing + download info, verify URL rewriting to releases_remote | +| `e2e/goproxy_test.go` | Proxy Go module `@v/list`, `.info`, `.mod`, `.zip` through GOPROXY remote, verify mutable vs immutable classification | +| `e2e/gc_test.go` | Create artifact, delete remote, trigger GC, verify S3 blob cleaned up | + +### Code Quality + +- `gofmt` / `goimports` — enforced in CI, run on save +- `golangci-lint` — comprehensive linter suite (staticcheck, errcheck, govet, etc.) +- `go vet ./...` — run in CI +- Makefile targets: `make test`, `make lint`, `make e2e`, `make fmt` + +--- + +## Terraform Provider (Separate Repo) + +**Repo**: `terraform-provider-artifactapi` +**Uses**: `pkg/client/` and `pkg/models/` from the main module + +```hcl +provider "artifactapi" { + endpoint = "https://artifactapi.k8s.syd1.au.unkin.net" +} + +resource "artifactapi_remote" "terraform_registry" { + name = "terraform-registry" + package_type = "terraform" + base_url = "https://registry.terraform.io" + description = "Terraform provider registry" + releases_remote = artifactapi_remote.hashicorp_releases.name + + immutable_patterns = [ + "[^/]+/[^/]+/[^/]+/download/[^/]+/[^/]+$", + ] + + cache { + immutable_ttl = 0 + mutable_ttl = 300 + } +} + +resource "artifactapi_remote" "hashicorp_releases" { + name = "hashicorp-releases" + package_type = "generic" + base_url = "https://releases.hashicorp.com" + + immutable_patterns = [ + ".*\\.zip$", + ".*SHA256SUMS(\\.sig)?$", + ] + + cache { + immutable_ttl = 0 + mutable_ttl = 0 + } +} + +resource "artifactapi_virtual" "helm" { + name = "helm" + package_type = "helm" + description = "All helm repos merged" + members = [ + artifactapi_remote.jetstack.name, + artifactapi_remote.hashicorp_helm.name, + ] +} +``` + +--- + +## Web UI (React + Vite — Separate Container) + +### Deployment + +Separate `Dockerfile.ui`: multi-stage build (node → nginx). Served as its own container/pod. nginx proxies `/api/*` to the Go backend. + +### Pages + +| Route | Content | +|---|---| +| `/` | Dashboard: total objects, storage used, dedup savings, bandwidth saved, top remotes chart, live SSE event feed, health indicators | +| `/remotes` | Remote table: name, type, description, object count, size, hit rate, health. Filter by type, sort any column | +| `/remotes/:name` | Config (read-only, "Managed by Terraform" badge), stats, object browser with search/sort/evict, flush actions | +| `/virtuals` | Virtual table: name, type, members, merged object count | +| `/virtuals/:name` | Member list with individual stats | + +All config is read-only — managed by Terraform. + +--- + +## TUI (Bubble Tea — Subcommand) + +`artifactapi tui --endpoint http://localhost:8000` or via `ARTIFACTAPI_ENDPOINT` env. + +Uses `pkg/client/` for all API calls (same client as Terraform provider). + +| View | Key bindings | +|---|---| +| Dashboard | summary stats, top remotes | +| Remotes list | `j`/`k` navigate, `/` filter, `Enter` detail | +| Remote detail | config + stats, `Enter` → object browser | +| Object browser | `/` search, `d` evict, `f` flush | +| Virtuals | `j`/`k`, `Enter` detail | + +--- + +## Improvements Over v2 + +| Area | v2 (Python) | v3 (Go) | +|---|---|---| +| S3 paths | Hashed, opaque | Content-addressed CAS | +| Config | YAML files, mtime reload | Terraform via API | +| Package types | 8 types | 10 types (+ terraform, goproxy) | +| Virtual repos | Helm only | Helm + PyPI, extensible | +| Deduplication | Docker blobs only | All types via CAS | +| Revalidation | Opt-in flag | Default for all mutable | +| Access logging | None | Per-artifact in Postgres | +| GC | None | Background goroutine | +| Upstream health | Per-request | Circuit breaker | +| S3 backends | MinIO only | MinIO, Ceph, AWS (minio-go) | +| UI | None | Web dashboard + TUI | +| Binary | Python + venv | Static Go binary | +| Frontend | N/A | Separate container (React) | +| Testing | Mocked unit tests | Unit + e2e with real backends | + +--- + +## Implementation Phases + +### Phase 1: Core Engine + Models +- Go module, Makefile (`make build test lint fmt e2e`), Dockerfile, docker-compose +- `pkg/models/` — all domain types +- PostgreSQL schema + migrations +- S3 storage layer with CAS (`minio-go/v7`) +- Redis cache layer (TTL, locks) +- Proxy engine: fetch-or-cache, classifier, revalidator +- Generic + Docker providers (most complexity: OCI auth, CAS, tag banning) +- Health + metrics endpoints +- Unit tests for all packages +- **Milestone**: proxy Docker + generic, cache in S3, track in Postgres + +### Phase 2: All Providers +- Helm (using `helm.sh/helm/v3/pkg/repo`) +- PyPI (stdlib `x/net/html`) +- npm (stdlib `encoding/json`) +- RPM (using `rs3.io/go/rpm/repomd`) +- Alpine (using `gitlab.alpinelinux.org/alpine/go`) +- Puppet Forge (stdlib `encoding/json`) +- Terraform (using `hashicorp/terraform-registry-address`) +- Go Modules / GOPROXY (using `github.com/goproxy/goproxy`) +- Unit tests per provider +- **Milestone**: feature parity with v2 + goproxy + +### Phase 3: Management API + Virtual Repos + GC +- `pkg/client/` — shared Go API client +- v2 CRUD endpoints +- Virtual repo engine: `IndexMerger` for Helm + PyPI +- Circuit breaker +- Access logging middleware +- GC goroutine +- **Milestone**: full API, virtuals, GC + +### Phase 4: End-to-End Tests +- `e2e/` test suite with `testcontainers-go` +- Proxy, Docker, management, virtual, terraform, GC tests +- CI pipeline: `make e2e` +- **Milestone**: comprehensive e2e coverage + +### Phase 5: Terraform Provider +- Separate repo: `terraform-provider-artifactapi` +- Imports `pkg/client/` and `pkg/models/` +- `artifactapi_remote` + `artifactapi_virtual` resources + data sources +- Import support +- **Milestone**: manage all config via Terraform + +### Phase 6: Web UI +- React + Vite in `ui/` +- `Dockerfile.ui` (multi-stage → nginx) +- Dashboard, remotes, objects, virtuals pages +- SSE event feed +- **Milestone**: full web UI in separate container + +### Phase 7: TUI +- Bubble Tea in `internal/tui/` +- Uses `pkg/client/` +- Dashboard, remotes, objects, virtuals views +- **Milestone**: TUI feature parity with web UI + +### Phase 8: Migration + Cutover +- Migration tool: v2 YAML → Terraform HCL + `terraform import` commands +- S3 rehash script: `{remote}/{hash16}/{file}` → `blobs/sha256/{content_hash}` +- Parallel run, response comparison +- Cutover + +--- + +## Makefile Targets + +```makefile +.PHONY: build test lint fmt e2e docker docker-ui + +build: ## Build Go binary + go build -o bin/artifactapi ./cmd/artifactapi + +test: ## Run unit tests + go test ./... + +lint: ## Run golangci-lint + go vet + golangci-lint run ./... + go vet ./... + +fmt: ## Format code (gofmt + goimports) + gofmt -w . + goimports -w . + +e2e: ## Run end-to-end tests (requires Docker) + go test -tags=e2e -count=1 -timeout=5m ./e2e/... + +docker: ## Build API server Docker image + docker build -t artifactapi . + +docker-ui: ## Build frontend Docker image + docker build -t artifactapi-ui -f ui/Dockerfile.ui ui/ + +compose: ## Start full stack (API + UI + Postgres + Redis + MinIO) + docker compose up -d +``` diff --git a/README.md b/README.md index a03f363..3180214 100644 --- a/README.md +++ b/README.md @@ -1,582 +1,167 @@ -# Artifact Storage System +# ArtifactAPI -FastAPI caching proxy that downloads and stores files from remote sources in S3-compatible storage. +Caching proxy for package repositories. Single Go binary, 10 package types, content-addressable storage, managed by Terraform. -## Features +## Quick Start -- Remote definitions via `remotes.yaml` — generic HTTP, Alpine APK, RPM, Docker, PyPI, npm, Helm, Puppet Forge, Terraform/OpenTofu registry -- Virtual repositories — merge multiple remotes of the same package type into a single unified index -- Immutable/mutable caching model with per-remote TTLs -- Conditional revalidation (`If-None-Match` / `If-Modified-Since`) on TTL expiry -- Stale-on-upstream-error: refreshes TTL when backend is unreachable rather than evicting -- URL rewriting for PyPI simple index, npm metadata, and Helm `index.yaml` -- Access control via regex patterns — unmatched paths return 403 -- Docker tag banning — block named tags (e.g. `latest`) while allowing digest pulls +```bash +# Start backing services +docker compose up -d postgres redis minio + +# Build and run +make build +./bin/artifactapi + +# Frontend (separate container or dev server) +cd ui && npm install && npm run dev +``` + +API: `http://localhost:8000` | Frontend: `http://localhost:5173` + +## Package Types + +| Type | Mutable (auto-detected) | Immutable (auto-detected) | +|---|---|---| +| `generic` | nothing | everything | +| `docker` | tag manifests, `/tags/list` | blobs, digest manifests | +| `helm` | `index.yaml` | `.tgz` charts | +| `pypi` | `simple/*` index pages | `.whl`, `.tar.gz` | +| `npm` | package metadata | `.tgz` tarballs | +| `rpm` | `repomd.xml`, `repodata/*` | `.rpm` | +| `alpine` | `APKINDEX.tar.gz` | `.apk` | +| `puppet` | `v3/modules/*`, `v3/releases*` | `.tar.gz` | +| `terraform` | `*/versions` | `*/download/*/*` | +| `goproxy` | `@v/list`, `@latest` | `.info`, `.mod`, `.zip` | + +Providers classify paths automatically. Users only configure what to proxy and TTLs. + +## Terraform + +Remotes and virtuals are managed by Terraform. Each package type has its own resource: + +```hcl +resource "artifactapi_remote_generic" "github" { + name = "github" + base_url = "https://github.com" + + immutable_ttl = 0 + mutable_ttl = 7200 + + patterns = [ + "ducaale/xh/.*/xh-.*-x86_64-unknown-linux-musl.tar.gz$", + "mikefarah/yq/.*/yq_linux_amd64$", + ] + + mutable_patterns = [ + ".*/archive/refs/heads/.*\\.tar\\.gz$", + ] +} + +resource "artifactapi_remote_docker" "dockerhub" { + name = "dockerhub" + base_url = "https://registry-1.docker.io" + + immutable_ttl = 0 + mutable_ttl = 300 + ban_tags_enabled = true + ban_tags = ["latest"] + + patterns = [ + "^library/postgres", + "^library/redis", + ] +} + +resource "artifactapi_remote_helm" "jetstack" { + name = "jetstack" + base_url = "https://charts.jetstack.io" + + immutable_ttl = 0 + mutable_ttl = 3600 +} + +resource "artifactapi_virtual" "helm" { + name = "helm" + package_type = "helm" + members = [artifactapi_remote_helm.jetstack.name] +} +``` + +Provider: [terraform-provider-artifactapi](../terraform-provider-artifactapi) + +## Access Control + +| Field | Default | Behaviour | +|---|---|---| +| `patterns` | empty (proxy all) | If set, only matching paths are proxied. Acts as allowlist. | +| `blocklist` | empty | Matching paths always denied. Checked first. | +| `mutable_patterns` | empty | Override: force paths to mutable TTL. | +| `immutable_patterns` | empty | Override: force paths to immutable TTL. | + +No patterns + no blocklist = open proxy. Provider handles mutability classification automatically. + +## API + +### Proxy (v1) + +``` +GET /api/v1/remote/{name}/{path} Proxy/cache artifact +GET /api/v1/virtual/{name}/{path} Virtual repo (merged index) +GET /v2/{name}/{path} Docker Registry v2 +``` + +### Management (v2) + +``` +GET/POST /api/v2/remotes List / create remotes +GET/PUT/DELETE /api/v2/remotes/{name} Read / update / delete remote +GET/DELETE /api/v2/remotes/{name}/objects Browse / evict cached objects +GET /api/v2/stats Overview stats +GET /api/v2/health Service health +POST /api/v2/probe Test a remote (fetch without streaming to client) +GET /api/v2/events SSE event stream +``` ## Architecture ``` -client → /api/v1/remote/{remote}/{path} - ↓ - Redis: mutable TTL check - ↓ miss / expired - S3: object exists? - ↓ no - upstream remote → S3 + PostgreSQL metadata - ↓ - response (X-Artifact-Source: cache|remote) +PostgreSQL ─── config (remotes, virtuals), artifact metadata, access log +Redis ─── TTL keys, fetch locks, circuit breaker state +S3/MinIO ─── content-addressable blob storage (blobs/sha256/{hash}) ``` -Docker Registry traffic uses the `/v2/{remote}/{path}` endpoint implementing the Docker Registry HTTP API v2. +S3 client supports MinIO, Ceph RGW, and AWS S3 (via minio-go). -### Code layout +## Environment Variables -``` -src/artifactapi/ -├── main.py — FastAPI app + thin route declarations only -├── config.py — ConfigManager (loads remotes.yaml) -├── metrics.py — Prometheus + Redis metrics -├── docker_auth.py — backwards-compat shim → auth/docker.py -├── artifact/ — route handler implementations -│ ├── proxy.py — GET /api/v1/remote (remote proxy, cache, revalidation) -│ ├── virtual.py — GET /api/v1/virtual (virtual repo index merging) -│ ├── local.py — PUT/HEAD/DELETE /api/v1/remote (local repos) -│ ├── docker.py — /v2/ Docker Registry v2 proxy -│ ├── discovery.py — /api/v1/artifacts discovery + bulk cache -│ └── flush.py — PUT /cache/flush -├── auth/ -│ ├── __init__.py — re-exports Docker auth helpers -│ └── docker.py — Bearer token fetching + in-memory cache -├── cache/ -│ ├── __init__.py — re-exports RedisCache -│ └── redis.py — RedisCache (TTL keys, ETag metadata) -├── database/ -│ ├── __init__.py — re-exports DatabaseManager -│ └── postgres.py — DatabaseManager (artifact + local-file tables) -├── storage/ -│ ├── __init__.py — re-exports S3Storage -│ └── s3.py — S3Storage (MinIO/S3 abstraction) -└── remote/ - ├── __init__.py - ├── base.py — content-type detection - ├── generic.py — generic HTTP remotes - ├── helm.py — Helm index.yaml URL rewriting - ├── npm.py — npm metadata URL rewriting - ├── puppet.py — Puppet Forge JSON URL rewriting - ├── python.py — PyPI URL construction + HTML rewriting - ├── rpm.py — RPM remotes - └── terraform.py — Terraform/OpenTofu registry URL construction + download URL rewriting -``` - -## API Endpoints - -| Method | Path | Description | +| Variable | Default | Description | |---|---|---| -| `GET` | `/api/v1/remote/{remote}/{path}` | Fetch artifact (auto-cache on miss) | -| `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 | -| `PUT` | `/cache/flush` | Flush cache entries | -| `GET` | `/health` | Health check | -| `GET` | `/config` | View loaded configuration | -| `GET` | `/` | API info and available remotes | +| `LISTEN_ADDR` | `:8000` | Server listen address | +| `DBHOST` | `localhost` | PostgreSQL host | +| `DBPORT` | `5432` | PostgreSQL port | +| `DBUSER` | `artifacts` | PostgreSQL user | +| `DBPASS` | | PostgreSQL password | +| `DBNAME` | `artifacts` | PostgreSQL database | +| `REDIS_URL` | `redis://localhost:6379` | Redis URL | +| `MINIO_ENDPOINT` | `localhost:9000` | S3 endpoint | +| `MINIO_ACCESS_KEY` | | S3 access key | +| `MINIO_SECRET_KEY` | | S3 secret key | +| `MINIO_BUCKET` | `artifacts` | S3 bucket | +| `MINIO_SECURE` | `false` | Use HTTPS for S3 | +| `MINIO_REGION` | | S3 region (AWS) | -## Configuration - -Runtime settings come from environment variables; remote definitions live in one or more YAML files pointed to by `CONFIG_PATH`. - -### Environment Variables - -| Variable | Description | -|---|---| -| `CONFIG_PATH` | Path to a config YAML file **or** a directory of YAML files | -| `DBHOST`, `DBPORT`, `DBUSER`, `DBPASS`, `DBNAME` | PostgreSQL connection | -| `REDIS_URL` | Redis URL (e.g. `redis://localhost:6379`) | -| `MINIO_ENDPOINT` | MinIO/S3 endpoint | -| `MINIO_ACCESS_KEY` | S3 access key | -| `MINIO_SECRET_KEY` | S3 secret key | -| `MINIO_BUCKET` | S3 bucket name | -| `MINIO_SECURE` | Use HTTPS (`true`/`false`) | - -### Split configuration - -`CONFIG_PATH` accepts three forms: - -**Single file** (original behaviour): -``` -CONFIG_PATH=/etc/artifactapi/remotes.yaml -``` - -**Directory** — all `*.yaml` / `*.yml` files in the directory are loaded and merged alphabetically. `remotes` keys are merged across files; later files win on conflict: -``` -CONFIG_PATH=/etc/artifactapi/conf.d/ -``` - -**Main file + `config_dir`** — the main file holds global settings and a `config_dir` pointer; each file in that directory contributes its own `remotes`. Relative `config_dir` paths are resolved relative to the main file: -```yaml -# /etc/artifactapi/config.yaml -config_dir: conf.d # or an absolute path - -# s3/redis/database settings go here (or in env vars) -remotes: {} # optional base remotes -``` - -### Configuration structure - -Repositories are declared under three top-level keys matching their type: - -```yaml -remotes: # proxy (caching) remotes - remote-name: - base_url: "https://example.com" - package: "generic" # generic, alpine, rpm, docker, pypi, npm, helm, puppet, terraform - description: "..." - immutable_patterns: # regex — cached forever - - ".*\\.tar\\.gz$" - mutable_patterns: # regex — expire after mutable_ttl - - "index\\.yaml$" - check_mutable_updates: false # send HEAD (If-None-Match) on TTL expiry - cache: - immutable_ttl: 0 # 0 = indefinitely - mutable_ttl: 3600 - -virtuals: # virtual (merged-index) repositories - virtual-name: - package: "helm" - members: - - remote-a - - remote-b - -locals: # local upload repositories (no base_url) - local-name: - package: "generic" - cache: - immutable_ttl: 0 - mutable_ttl: 0 -``` - -## Remote Types - -### generic - -Arbitrary HTTP file servers — GitHub releases, HashiCorp, custom servers. - -```yaml -remotes: - github: - base_url: "https://github.com" - package: "generic" - immutable_patterns: - - "gruntwork-io/terragrunt/.*terragrunt_linux_amd64.*" - cache: - immutable_ttl: 0 - - github-archive: - base_url: "https://github.com" - package: "generic" - immutable_patterns: - - ".*/archive/refs/tags/.*\\.tar\\.gz$" # tag archives never change - mutable_patterns: - - ".*/archive/refs/heads/main\\.tar\\.gz$" # branch archives can change - check_mutable_updates: true - cache: - immutable_ttl: 0 - mutable_ttl: 86400 -``` - -Access: `GET /api/v1/remote/github/owner/repo/releases/download/v1.0/binary.tar.gz` - -### alpine - -```yaml -remotes: - alpine: - base_url: "https://dl-cdn.alpinelinux.org" - package: "alpine" - immutable_patterns: - - ".*/x86_64/.*\\.apk$" - cache: - immutable_ttl: 0 - mutable_ttl: 7200 -``` - -`APKINDEX.tar.gz` is a built-in mutable pattern — no `mutable_patterns` entry needed. - -### rpm - -```yaml -remotes: - almalinux: - base_url: "https://mirror.example.com/almalinux" - package: "rpm" - immutable_patterns: - - ".*/x86_64/.*\\.rpm$" - - ".*/noarch/.*\\.rpm$" - cache: - immutable_ttl: 0 - mutable_ttl: 7200 -``` - -`repomd.xml` and `repodata/` metadata files are built-in mutable patterns. - -### docker - -```yaml -remotes: - dockerhub: - base_url: "https://registry-1.docker.io" - package: "docker" - # username / password optional for public images - cache: - immutable_ttl: 0 - mutable_ttl: 300 - - ghcr: - base_url: "https://ghcr.io" - package: "docker" - username: "your-github-username" - password: "ghp_your_pat" # read:packages scope - cache: - immutable_ttl: 0 - mutable_ttl: 300 -``` - -Tag manifests and `/tags/list` are built-in mutable patterns. Digest-addressed blobs are immutable. - -#### Banning tags - -Set `ban_tags_enabled: true` and list named tags in `ban_tags` to block specific tag references. Requests for a banned tag return `403`. Digest-addressed pulls (`sha256:…`) are never blocked, so images already in use can still be referenced by digest. - -```yaml -remotes: - dockerhub: - base_url: "https://registry-1.docker.io" - package: "docker" - ban_tags_enabled: true - ban_tags: - - latest # force pinned tags in CI/CD - - edge - cache: - immutable_ttl: 0 - mutable_ttl: 300 -``` - -`ban_tags_enabled` defaults to `false`. Setting it to `true` with an empty `ban_tags` list has no effect. - -For RKE2/containerd, configure `/etc/rancher/rke2/registries.yaml`: - -```yaml -mirrors: - docker.io: - endpoint: - - "https://artifacts.example.com" - rewrite: - "^(.*)$": "dockerhub/$1" - ghcr.io: - endpoint: - - "https://artifacts.example.com" - rewrite: - "^(.*)$": "ghcr/$1" -``` - -### pypi - -```yaml -remotes: - pypi: - base_url: "https://files.pythonhosted.org" - package: "pypi" - check_mutable_updates: true - immutable_patterns: - - "packages/.*\\.whl$" - - "packages/.*\\.whl\\.metadata$" - - "packages/.*\\.tar\\.gz$" - - "packages/.*\\.zip$" - cache: - immutable_ttl: 0 - mutable_ttl: 600 -``` - -> **Note**: Simple index requests (`/simple/{package}/`) are always fetched from `https://pypi.org`, regardless of `base_url`. This is hardcoded — `base_url` only controls where package files are downloaded from. For self-hosted registries (Gitea, Nexus) where both index and files share the same host, set `base_url` to that host and the override does not apply. - -URLs in simple index HTML are rewritten to route package file downloads back through the same remote. - -Configure uv: - -```toml -# /etc/uv/uv.toml or ~/.config/uv/uv.toml -[[index]] -url = "https://artifacts.example.com/api/v1/remote/pypi/simple" -default = true -``` - -### npm - -```yaml -remotes: - npm: - base_url: "https://registry.npmjs.org" - package: "npm" - check_mutable_updates: true - immutable_patterns: - - "\.tgz$" - mutable_patterns: - - "^(?!.*\.tgz$).*" - cache: - immutable_ttl: 0 - mutable_ttl: 600 -``` - -`dist.tarball` URLs in package metadata JSON are rewritten to route tarball downloads back through the same remote. - -Configure npm / yarn / pnpm: - -```ini -# .npmrc or ~/.npmrc -registry=https://artifacts.example.com/api/v1/remote/npm/ -``` - -### helm - -```yaml -remotes: - hashicorp-helm: - base_url: "https://helm.releases.hashicorp.com" - package: "helm" - check_mutable_updates: true - immutable_patterns: - - "\\.tgz$" - cache: - immutable_ttl: 0 - mutable_ttl: 3600 -``` - -`index.yaml` is a built-in mutable pattern. Chart URLs inside `index.yaml` are rewritten to route tarball downloads back through the same remote. - -Configure Helm: +## Development ```bash -helm repo add hashicorp https://artifacts.example.com/api/v1/remote/hashicorp-helm -helm repo update +make build # Build binary +make test # Unit tests +make e2e # E2E tests (needs Docker) +make lint # golangci-lint + go vet +make fmt # gofmt + goimports ``` -### puppet - -Proxy for [Puppet Forge](https://forge.puppet.com) (forgeapi.puppet.com). Module metadata is cached as mutable; versioned module tarballs are cached as immutable. - -```yaml -remotes: - puppet-forge: - base_url: "https://forgeapi.puppet.com" - package: "puppet" - check_mutable_updates: true - immutable_patterns: - - "^v3/files/.*\\.tar\\.gz$" - cache: - immutable_ttl: 0 # module tarballs cached indefinitely - mutable_ttl: 600 # module metadata refreshed after 10 minutes -``` - -`v3/modules/` and `v3/releases` are built-in mutable patterns — module metadata pages expire after `mutable_ttl` and are re-fetched on the next request. - -**URL rewriting**: the proxy rewrites `file_uri` fields in Forge JSON responses from relative paths (`/v3/files/…`) to absolute proxy URLs. g10k resolves download URLs with Go's `url.ResolveReference`, so an absolute `file_uri` overrides the forge base entirely — tarballs download straight from the proxy without a second hop. - -**Client configuration — g10k**: set `forge_base_url` in the g10k config file: - -```yaml -# g10k.yaml -cachedir: /tmp/g10k -forge_base_url: https://artifacts.example.com/api/v1/remote/puppet-forge -sources: - control: - remote: git@git.example.com:puppet/control.git - basedir: /etc/puppetlabs/code/environments -``` - -Alternatively, set the URL per-Puppetfile with the `forge.baseUrl` directive (works with `-puppetfile` mode and does not require a config file): - -```ruby -forge.baseUrl https://artifacts.example.com/api/v1/remote/puppet-forge - -mod 'puppetlabs-stdlib', '9.7.0' -mod 'puppetlabs-inifile', '6.2.0' -``` - -### terraform - -Proxy for [Terraform](https://registry.terraform.io) / [OpenTofu](https://opentofu.org) provider registries using the [Registry Protocol](https://developer.hashicorp.com/terraform/internals/provider-registry-protocol). Provider version listings are mutable; per-version download info is immutable. - -Two remotes are needed: one for the registry API and one for the release CDN (where the actual `.zip` binaries live): - -```yaml -remotes: - terraform-registry: - base_url: "https://registry.terraform.io" - package: "terraform" - releases_remote: "hashicorp-releases" # name of the CDN remote below - immutable_patterns: - - "[^/]+/[^/]+/[^/]+/download/[^/]+/[^/]+$" - cache: - immutable_ttl: 0 # per-version download info cached indefinitely - mutable_ttl: 300 # provider version lists refreshed after 5 minutes - - hashicorp-releases: - base_url: "https://releases.hashicorp.com" - package: "generic" - immutable_patterns: - - ".*\\.zip$" - - ".*SHA256SUMS(\\.sig)?$" - cache: - immutable_ttl: 0 - mutable_ttl: 0 -``` - -`{namespace}/{type}/versions` is a built-in mutable pattern — the version list expires after `mutable_ttl` and is re-fetched on the next request. - -**URL rewriting**: the `download_url`, `shasums_url`, and `shasums_signature_url` fields in per-version download info JSON are rewritten from `releases.hashicorp.com` to point at the remote named by `releases_remote`, so Terraform fetches binaries through the proxy. - -**Client configuration**: redirect Terraform's provider registry lookup via `.terraformrc` without changing any provider source addresses in your Terraform code: - -```hcl -# ~/.terraformrc (or /etc/terraform.rc, or TF_CLI_CONFIG_FILE) -host "registry.terraform.io" { - services = { - "providers.v1" = "http://artifacts.example.com/api/v1/remote/terraform-registry/" - } -} -``` - -With this in place, `terraform init` / `tofu init` fetches provider metadata from the proxy and downloads zips from the `hashicorp-releases` remote. No changes to `.tf` files are needed. - -### virtual - -A virtual repository presents a single unified index built from multiple member remotes of the same package type. Clients configure one endpoint and get access to all member remotes transparently. - -All members must share the same `package` type as the virtual repo. Currently supported package types: `helm`. - -```yaml -remotes: - helm-hashicorp: - base_url: "https://helm.releases.hashicorp.com" - package: "helm" - immutable_patterns: - - "\\.tgz$" - cache: - immutable_ttl: 0 - mutable_ttl: 3600 - - helm-bitnami: - base_url: "https://charts.bitnami.com/bitnami" - package: "helm" - immutable_patterns: - - "\\.tgz$" - cache: - immutable_ttl: 0 - mutable_ttl: 3600 - -virtuals: - helm-all: - package: "helm" - members: - - helm-hashicorp # listed first = highest priority - - helm-bitnami -``` - -**How it works:** - -1. A request for the package index triggers a parallel fetch of each member's index from S3 cache, falling back to upstream if not yet cached. -2. Member indexes are merged into a single index with URL rewriting so artifact download URLs continue to resolve through the individual member remote. -3. The merged index is cached in Redis with a TTL equal to the minimum `mutable_ttl` across all members. - -**Priority / conflict resolution:** - -When the same artifact name and version appears in more than one member, the member listed **first** in `members` wins. Subsequent members contribute only artifacts not already present. - -**Partial failures:** - -If a member is unreachable and has no cached index, it is skipped and a warning is logged. The merged index is still served from available members. If *no* members can be reached, the request returns `502`. - -**Caching:** - -The merged index is cached using `min(mutable_ttl)` across all members. Each member's raw index is cached in S3 under its own remote key; the virtual handler reuses those copies when available. On rebuild, each member's parsed index is also stored as a compact msgpack file (`index.msgpack`) alongside the raw YAML, eliminating the YAML parse cost on subsequent rebuilds. - -**Helm example:** +### TUI ```bash -helm repo add all https://artifacts.example.com/api/v1/virtual/helm-all -helm repo update +./bin/artifactapi tui --endpoint http://localhost:8000 ``` - -Chart tarball URLs in the merged `index.yaml` are rewritten to point at the individual member remote (e.g. `…/api/v1/remote/helm-hashicorp/vault-0.27.0.tgz`), so downloads bypass the virtual endpoint entirely. - -### local - -```yaml -locals: - local-generic: - package: "generic" - description: "Local file repository" - cache: - immutable_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}`. - -## Caching Model - -### Immutable patterns - -Files matching `immutable_patterns` are cached for `immutable_ttl` seconds (0 = indefinitely). Use for versioned release artifacts that never change once published. - -**Access control**: only paths matching an immutable or mutable pattern are served; all others return 403. Omitting `immutable_patterns` entirely allows all paths from that remote. - -### Mutable patterns - -Files matching `mutable_patterns` expire after `mutable_ttl` seconds and are re-fetched on the next request. Mutable files are always served regardless of `immutable_patterns`. - -Each package type has built-in defaults that are merged with any user-defined `mutable_patterns`: - -| Package type | Built-in mutable patterns | -|---|---| -| `alpine` | `APKINDEX\.tar\.gz$` | -| `rpm` | `repomd\.xml$`, `repodata/` metadata variants, `Packages\.gz$` | -| `docker` | Tag manifests (non-digest refs), `/tags/list` | -| `pypi` | `simple/` (per-package and top-level index pages) | -| `helm` | `index\.yaml$` | -| `puppet` | `^v3/modules/`, `^v3/releases` | -| `terraform` | `[^/]+/[^/]+/versions$` | -| `npm` | *(none built-in — define via `mutable_patterns`)* | -| `generic` | *(none)* | - -### Conditional revalidation - -Set `check_mutable_updates: true` to send `HEAD` with `If-None-Match` / `If-Modified-Since` on TTL expiry. A 304 response refreshes the TTL without re-downloading. Only applies to user-defined `mutable_patterns` — built-in patterns are always re-fetched unconditionally. - -### Stale-on-upstream-error - -When a mutable file expires and the upstream is unreachable (connection refused, DNS failure, timeout), the cached copy is kept and its TTL refreshed. HTTP error responses (4xx, 5xx) are not treated as network failures and proceed with normal expiry. - -### Quarantine (supply-chain protection) - -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 -remotes: - pypi: - base_url: "https://files.pythonhosted.org" - package: "pypi" - quarantine_new: true - quarantine_days: 3 # block packages published in the last 3 days - immutable_patterns: - - "packages/.*\\.whl$" - - "packages/.*\\.tar\\.gz$" - cache: - immutable_ttl: 0 - mutable_ttl: 600 -``` - -The upstream `Last-Modified` response header is used as the publish date proxy. Artifacts that have no `Last-Modified` header are allowed through (fail-open). Mutable files (index pages, tag manifests) are never quarantined. diff --git a/SPEC.md b/SPEC.md deleted file mode 100644 index 9beb93b..0000000 --- a/SPEC.md +++ /dev/null @@ -1,137 +0,0 @@ -# ArtifactAPI Specification - -## Repository model - -Every repository entry in `remotes.yaml` has two orthogonal fields: - -| field | values | meaning | -|---|---|---| -| `type` | `local`, `remote`, `virtual` | repository kind — how the repo is served | -| `package` | `docker`, `rpm`, `alpine`, `generic` | package format — what protocol and caching rules to apply | - -**type** - -- `local` — files are uploaded directly to the API and stored in S3; no upstream. -- `remote` — proxies and caches content from an upstream URL (`base_url`). -- `virtual` — aggregates multiple repositories (not yet implemented). - -**package** - -- `docker` — upstream speaks the OCI Distribution API (Bearer auth, manifest/blob paths). -- `rpm` — upstream is an RPM repository; repodata files are index files. -- `alpine` — upstream is an Alpine APK repository; `APKINDEX.tar.gz` is an index file. -- `generic` — plain HTTP file download; no format-specific logic. - ---- - -## Caching - -Two cache classes determine retention: - -| class | stored | TTL | -|---|---|---| -| **file** | S3 object, no Redis entry | `file_ttl` — `0` means indefinite | -| **index** | S3 object + Redis TTL key | `index_ttl` — when the Redis key expires the S3 object is deleted and re-fetched | - -Index files are mutable metadata that must expire. File-class objects are treated as immutable and cached indefinitely (unless `file_ttl` is set). - ---- - -## Docker package rules - -### URL construction - -Remote URLs are prefixed with `/v2/` for `package: docker` remotes: - -``` -{base_url}/v2/{path} -``` - -e.g. `library/nginx/manifests/latest` → `https://registry-1.docker.io/v2/library/nginx/manifests/latest` - -### Authentication - -Docker registries use Bearer token challenges. On a `401 Unauthorized` response, the API: - -1. Parses the `WWW-Authenticate: Bearer` header for `realm`, `service`, and `scope`. -2. Fetches a token from the auth realm, supplying `username`/`password` from the remote config if present. -3. Retries the request with `Authorization: Bearer `. - -Tokens are cached in-memory keyed by `(realm, service, scope, username)` and expire 30 seconds before their stated `expires_in`. - -### Cache classification - -| path pattern | mutable | class | TTL source | -|---|---|---|---| -| `/manifests/` | yes | index | `index_ttl` | -| `/tags/list` | yes | index | `index_ttl` | -| `/manifests/sha256:` | no | file | `file_ttl` | -| `/blobs/sha256:` | no | file | `file_ttl` | - -Tag-based manifests and tag lists are mutable and cached as index. Digest-pinned manifests and blobs are content-addressed and cached indefinitely as files. - -### Blob deduplication - -Blobs are stored under a digest-keyed path shared across all images on the same remote: - -``` -{remote_name}/blobs/sha256/{digest} -``` - -The same layer pulled by different images is stored once. - -### Accept headers - -| path | `Accept` header sent upstream | -|---|---| -| `/manifests/…` | `application/vnd.docker.distribution.manifest.v2+json`, `application/vnd.oci.image.manifest.v1+json`, `application/vnd.oci.image.index.v1+json`, `application/vnd.docker.distribution.manifest.list.v2+json` | -| `/blobs/…` | `application/octet-stream` | - ---- - -## OCI Distribution API endpoint - -The API exposes a native Docker registry interface so clients can use `docker pull` directly: - -``` -GET /v2/ — version ping -GET /v2/{remote}/{image}/manifests/{ref} — fetch manifest -HEAD /v2/{remote}/{image}/manifests/{ref} — manifest metadata -GET /v2/{remote}/{image}/blobs/{digest} — fetch blob -HEAD /v2/{remote}/{image}/blobs/{digest} — blob metadata -``` - -Responses include `Docker-Distribution-Api-Version`, `Docker-Content-Digest`, and the correct OCI `Content-Type` (detected from the manifest `mediaType` field). - -Only remotes with `package: docker` are accessible via this endpoint. All other remotes return `400`. - ---- - -## include_patterns - -`include_patterns` is a list of Python regexes applied to every request before any upstream fetch or cache lookup. - -**Generic remotes (`/api/v1/remote/…`):** -- Patterns match against the file path and the full path. -- Index files (mutable metadata) bypass pattern checks and are always allowed. - -**Docker remotes (`/v2/…`):** -- Patterns match against the image name (first two path segments, e.g. `library/nginx`) and the full path. -- The index-file exemption does **not** apply — patterns restrict whole images, including their manifests and tag lists. -- No patterns configured → all images allowed. - -Returns `403` when a request is blocked. - ---- - -## Versioning - -The package version is derived from git tags via `hatch-vcs`. Tags follow the format `v{MAJOR}.{MINOR}.{PATCH}`. - -Docker images are built with the version injected at build time: - -``` -SETUPTOOLS_SCM_PRETEND_VERSION= uv sync --frozen -``` - -The `Makefile` provides `patch`, `minor`, and `major` targets that tag the current commit and rebuild the container image. diff --git a/cmd/artifactapi/main.go b/cmd/artifactapi/main.go new file mode 100644 index 0000000..d5529ed --- /dev/null +++ b/cmd/artifactapi/main.go @@ -0,0 +1,55 @@ +package main + +import ( + "context" + "fmt" + "log/slog" + "os" + "os/signal" + "syscall" + + "git.unkin.net/unkin/artifactapi/internal/config" + "git.unkin.net/unkin/artifactapi/internal/server" + "git.unkin.net/unkin/artifactapi/internal/tui" +) + +func main() { + if len(os.Args) > 1 && os.Args[1] == "tui" { + endpoint := os.Getenv("ARTIFACTAPI_ENDPOINT") + if endpoint == "" { + endpoint = "http://localhost:8000" + } + for i, arg := range os.Args { + if arg == "--endpoint" && i+1 < len(os.Args) { + endpoint = os.Args[i+1] + } + } + + app := tui.New(endpoint) + if err := app.Run(); err != nil { + fmt.Fprintf(os.Stderr, "tui error: %v\n", err) + os.Exit(1) + } + return + } + + cfg, err := config.Load() + if err != nil { + slog.Error("failed to load config", "error", err) + os.Exit(1) + } + + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + srv, err := server.New(cfg) + if err != nil { + slog.Error("failed to create server", "error", err) + os.Exit(1) + } + + if err := srv.Run(ctx); err != nil { + slog.Error("server exited with error", "error", err) + os.Exit(1) + } +} diff --git a/docker-compose.yml b/docker-compose.yml index cf69e36..7687e20 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,31 +1,22 @@ -version: '3.8' - services: artifactapi: - build: - context: . - dockerfile: Dockerfile - args: - - VERSION=2.2.2.dev0 + build: . ports: - "8000:8000" - volumes: - - ./examples/single-file/remotes.yaml:/app/remotes.yaml:ro,z - - ./ca-bundle.pem:/app/ca-bundle.pem:ro,z environment: - - CONFIG_PATH=/app/remotes.yaml - - DBHOST=postgres - - DBPORT=5432 - - DBUSER=artifacts - - DBPASS=artifacts123 - - DBNAME=artifacts - - REDIS_URL=redis://redis:6379 - - MINIO_ENDPOINT=minio:9000 - - MINIO_ACCESS_KEY=minioadmin - - MINIO_SECRET_KEY=minioadmin - - MINIO_BUCKET=artifacts - - MINIO_SECURE=false - - REQUESTS_CA_BUNDLE=/app/ca-bundle.pem + LISTEN_ADDR: ":8000" + DBHOST: postgres + DBPORT: "5432" + DBUSER: artifacts + DBPASS: artifacts123 + DBNAME: artifacts + DBSSL: disable + REDIS_URL: redis://redis:6379 + MINIO_ENDPOINT: minio:9000 + MINIO_ACCESS_KEY: minioadmin + MINIO_SECRET_KEY: minioadmin + MINIO_BUCKET: artifacts + MINIO_SECURE: "false" depends_on: postgres: condition: service_healthy @@ -34,27 +25,35 @@ services: minio: condition: service_healthy healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8000/health"] - interval: 30s - timeout: 10s + test: ["CMD", "wget", "-q", "--spider", "http://localhost:8000/health"] + interval: 10s + timeout: 5s retries: 3 - minio: - image: minio/minio:latest + ui: + build: + context: ui + dockerfile: Dockerfile.ui ports: - - "9000:9000" - - "9001:9001" + - "8080:80" + depends_on: + - artifactapi + + postgres: + image: postgres:17-alpine + ports: + - "5432:5432" environment: - MINIO_ROOT_USER: minioadmin - MINIO_ROOT_PASSWORD: minioadmin - command: server /data --console-address ":9001" + POSTGRES_USER: artifacts + POSTGRES_PASSWORD: artifacts123 + POSTGRES_DB: artifacts volumes: - - minio_data:/data + - postgres_data:/var/lib/postgresql/data healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] - interval: 30s - timeout: 20s - retries: 3 + test: ["CMD-SHELL", "pg_isready -U artifacts -d artifacts"] + interval: 5s + timeout: 5s + retries: 5 redis: image: redis:7-alpine @@ -65,27 +64,28 @@ services: command: redis-server --save 20 1 healthcheck: test: ["CMD", "redis-cli", "ping"] - interval: 30s - timeout: 10s - retries: 3 + interval: 5s + timeout: 5s + retries: 5 - postgres: - image: postgres:15-alpine - ports: - - "5432:5432" + minio: + image: minio/minio:latest + command: server /data --console-address ":9001" environment: - POSTGRES_DB: artifacts - POSTGRES_USER: artifacts - POSTGRES_PASSWORD: artifacts123 + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minioadmin + ports: + - "9000:9000" + - "9001:9001" volumes: - - postgres_data:/var/lib/postgresql/data + - minio_data:/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U artifacts -d artifacts"] - interval: 30s - timeout: 10s - retries: 3 + test: ["CMD", "mc", "ready", "local"] + interval: 5s + timeout: 5s + retries: 5 volumes: - minio_data: - redis_data: postgres_data: + redis_data: + minio_data: diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go new file mode 100644 index 0000000..495f21c --- /dev/null +++ b/e2e/e2e_test.go @@ -0,0 +1,137 @@ +//go:build e2e + +package e2e + +import ( + "context" + "fmt" + "log" + "net/http" + "os" + "testing" + "time" + + "github.com/testcontainers/testcontainers-go" + tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres" + tcredis "github.com/testcontainers/testcontainers-go/modules/redis" + "github.com/testcontainers/testcontainers-go/wait" + + "git.unkin.net/unkin/artifactapi/internal/config" + "git.unkin.net/unkin/artifactapi/internal/server" +) + +var baseURL string + +func TestMain(m *testing.M) { + ctx := context.Background() + + pgContainer, err := tcpostgres.Run(ctx, + "postgres:17-alpine", + tcpostgres.WithDatabase("artifacts"), + tcpostgres.WithUsername("artifacts"), + tcpostgres.WithPassword("artifacts123"), + testcontainers.WithWaitStrategy( + wait.ForListeningPort("5432/tcp").WithStartupTimeout(30*time.Second), + ), + ) + if err != nil { + log.Fatalf("postgres: %v", err) + } + defer pgContainer.Terminate(ctx) + + redisContainer, err := tcredis.Run(ctx, + "redis:7-alpine", + testcontainers.WithWaitStrategy( + wait.ForListeningPort("6379/tcp").WithStartupTimeout(30*time.Second), + ), + ) + if err != nil { + log.Fatalf("redis: %v", err) + } + defer redisContainer.Terminate(ctx) + + minioContainer, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: testcontainers.ContainerRequest{ + Image: "minio/minio:latest", + ExposedPorts: []string{"9000/tcp"}, + Cmd: []string{"server", "/data"}, + Env: map[string]string{ + "MINIO_ROOT_USER": "minioadmin", + "MINIO_ROOT_PASSWORD": "minioadmin", + }, + WaitingFor: wait.ForHTTP("/minio/health/live").WithPort("9000/tcp").WithStartupTimeout(30 * time.Second), + }, + Started: true, + }) + if err != nil { + log.Fatalf("minio: %v", err) + } + defer minioContainer.Terminate(ctx) + + pgHost, _ := pgContainer.Host(ctx) + pgPort, _ := pgContainer.MappedPort(ctx, "5432/tcp") + redisHost, _ := redisContainer.Host(ctx) + redisPort, _ := redisContainer.MappedPort(ctx, "6379/tcp") + minioHost, _ := minioContainer.Host(ctx) + minioPort, _ := minioContainer.MappedPort(ctx, "9000/tcp") + + os.Setenv("DBHOST", pgHost) + os.Setenv("DBPORT", pgPort.Port()) + os.Setenv("DBUSER", "artifacts") + os.Setenv("DBPASS", "artifacts123") + os.Setenv("DBNAME", "artifacts") + os.Setenv("DBSSL", "disable") + os.Setenv("REDIS_URL", fmt.Sprintf("redis://%s:%s", redisHost, redisPort.Port())) + os.Setenv("MINIO_ENDPOINT", fmt.Sprintf("%s:%s", minioHost, minioPort.Port())) + os.Setenv("MINIO_ACCESS_KEY", "minioadmin") + os.Setenv("MINIO_SECRET_KEY", "minioadmin") + os.Setenv("MINIO_BUCKET", "artifacts-test") + os.Setenv("MINIO_SECURE", "false") + os.Setenv("LISTEN_ADDR", ":0") + + cfg, err := config.Load() + if err != nil { + log.Fatalf("config: %v", err) + } + cfg.ListenAddr = "127.0.0.1:0" + + srv, err := server.New(cfg) + if err != nil { + log.Fatalf("server: %v", err) + } + + srvCtx, cancel := context.WithCancel(ctx) + defer cancel() + + addr := startServer(srvCtx, srv) + baseURL = "http://" + addr + + code := m.Run() + cancel() + os.Exit(code) +} + +func startServer(ctx context.Context, srv *server.Server) string { + ln, err := findListener() + if err != nil { + log.Fatalf("listener: %v", err) + } + addr := ln.Addr().String() + + go srv.RunOnListener(ctx, ln) + + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + resp, err := http.Get("http://" + addr + "/health") + if err == nil && resp.StatusCode == 200 { + resp.Body.Close() + return addr + } + if resp != nil { + resp.Body.Close() + } + time.Sleep(50 * time.Millisecond) + } + log.Fatalf("server did not start in time at %s", addr) + return "" +} diff --git a/e2e/helpers_test.go b/e2e/helpers_test.go new file mode 100644 index 0000000..0468aee --- /dev/null +++ b/e2e/helpers_test.go @@ -0,0 +1,109 @@ +//go:build e2e + +package e2e + +import ( + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "strings" + "testing" +) + +func findListener() (net.Listener, error) { + return net.Listen("tcp", "127.0.0.1:0") +} + +func apiURL(path string) string { + return baseURL + path +} + +func createRemote(t *testing.T, body string) { + t.Helper() + resp, err := http.Post(apiURL("/api/v2/remotes"), "application/json", strings.NewReader(body)) + if err != nil { + t.Fatalf("create remote: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + b, _ := io.ReadAll(resp.Body) + t.Fatalf("create remote: status %d: %s", resp.StatusCode, b) + } +} + +func deleteRemote(t *testing.T, name string) { + t.Helper() + req, _ := http.NewRequest(http.MethodDelete, apiURL("/api/v2/remotes/"+name), nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("delete remote: %v", err) + } + resp.Body.Close() +} + +func getJSON(t *testing.T, url string) map[string]any { + t.Helper() + resp, err := http.Get(url) + if err != nil { + t.Fatalf("GET %s: %v", url, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + t.Fatalf("GET %s: status %d: %s", url, resp.StatusCode, b) + } + var result map[string]any + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + t.Fatalf("decode: %v", err) + } + return result +} + +func getBody(t *testing.T, url string) ([]byte, int) { + t.Helper() + resp, err := http.Get(url) + if err != nil { + t.Fatalf("GET %s: %v", url, err) + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + return b, resp.StatusCode +} + +func getString(t *testing.T, url string) string { + t.Helper() + b, status := getBody(t, url) + if status != http.StatusOK { + t.Fatalf("GET %s: status %d: %s", url, status, b) + } + return string(b) +} + +func assertStatus(t *testing.T, url string, wantStatus int) { + t.Helper() + resp, err := http.Get(url) + if err != nil { + t.Fatalf("GET %s: %v", url, err) + } + resp.Body.Close() + if resp.StatusCode != wantStatus { + t.Errorf("GET %s: got %d, want %d", url, resp.StatusCode, wantStatus) + } +} + +func deleteRequest(t *testing.T, url string) int { + t.Helper() + req, _ := http.NewRequest(http.MethodDelete, url, nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("DELETE %s: %v", url, err) + } + resp.Body.Close() + return resp.StatusCode +} + +func mustFormat(format string, args ...any) string { + return fmt.Sprintf(format, args...) +} diff --git a/e2e/management_test.go b/e2e/management_test.go new file mode 100644 index 0000000..ce05963 --- /dev/null +++ b/e2e/management_test.go @@ -0,0 +1,159 @@ +//go:build e2e + +package e2e + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "testing" +) + +func TestHealth(t *testing.T) { + result := getJSON(t, apiURL("/health")) + if result["status"] != "ok" { + t.Errorf("expected ok, got %v", result["status"]) + } +} + +func TestRoot(t *testing.T) { + result := getJSON(t, apiURL("/")) + if result["name"] != "artifactapi" { + t.Errorf("expected artifactapi, got %v", result["name"]) + } +} + +func TestRemoteCRUD(t *testing.T) { + createRemote(t, `{ + "name": "test-generic", + "package_type": "generic", + "base_url": "https://example.com", + "description": "test remote", + "mutable_ttl": 600, + "check_mutable": true, + "stale_on_error": true + }`) + defer deleteRemote(t, "test-generic") + + remote := getJSON(t, apiURL("/api/v2/remotes/test-generic")) + if remote["name"] != "test-generic" { + t.Errorf("expected test-generic, got %v", remote["name"]) + } + if remote["package_type"] != "generic" { + t.Errorf("expected generic, got %v", remote["package_type"]) + } + + req, _ := http.NewRequest(http.MethodPut, apiURL("/api/v2/remotes/test-generic"), + strings.NewReader(`{ + "package_type": "generic", + "base_url": "https://updated.example.com", + "description": "updated", + "mutable_ttl": 300, + "check_mutable": true, + "stale_on_error": true + }`)) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("update: status %d", resp.StatusCode) + } + + updated := getJSON(t, apiURL("/api/v2/remotes/test-generic")) + if updated["base_url"] != "https://updated.example.com" { + t.Errorf("expected updated URL, got %v", updated["base_url"]) + } + + status := deleteRequest(t, apiURL("/api/v2/remotes/test-generic")) + if status != http.StatusNoContent { + t.Errorf("delete: got %d, want 204", status) + } + + assertStatus(t, apiURL("/api/v2/remotes/test-generic"), http.StatusNotFound) +} + +func TestRemoteList(t *testing.T) { + createRemote(t, `{"name":"list-a","package_type":"generic","base_url":"https://a.example.com","stale_on_error":true}`) + createRemote(t, `{"name":"list-b","package_type":"helm","base_url":"https://b.example.com","stale_on_error":true}`) + defer deleteRemote(t, "list-a") + defer deleteRemote(t, "list-b") + + resp, err := http.Get(apiURL("/api/v2/remotes")) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + + var remotes []map[string]any + json.Unmarshal(body, &remotes) + + if len(remotes) < 2 { + t.Fatalf("expected at least 2 remotes, got %d", len(remotes)) + } +} + +func TestVirtualCRUD(t *testing.T) { + createRemote(t, `{"name":"virt-member-a","package_type":"helm","base_url":"https://a.example.com","stale_on_error":true}`) + createRemote(t, `{"name":"virt-member-b","package_type":"helm","base_url":"https://b.example.com","stale_on_error":true}`) + defer deleteRemote(t, "virt-member-a") + defer deleteRemote(t, "virt-member-b") + + resp, err := http.Post(apiURL("/api/v2/virtuals"), "application/json", + strings.NewReader(`{ + "name": "test-virtual", + "package_type": "helm", + "description": "test virtual", + "members": ["virt-member-a", "virt-member-b"] + }`)) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create virtual: status %d", resp.StatusCode) + } + + virt := getJSON(t, apiURL("/api/v2/virtuals/test-virtual")) + if virt["name"] != "test-virtual" { + t.Errorf("expected test-virtual, got %v", virt["name"]) + } + + status := deleteRequest(t, apiURL("/api/v2/virtuals/test-virtual")) + if status != http.StatusNoContent { + t.Errorf("delete virtual: got %d, want 204", status) + } +} + +func TestStatsEndpoint(t *testing.T) { + result := getJSON(t, apiURL("/api/v2/stats")) + if _, ok := result["total_remotes"]; !ok { + t.Error("expected total_remotes in stats") + } +} + +func TestHealthV2(t *testing.T) { + result := getJSON(t, apiURL("/api/v2/health")) + if result["status"] != "ok" { + t.Errorf("expected ok, got %v", result["status"]) + } + if result["postgres"] != "ok" { + t.Errorf("expected postgres ok, got %v", result["postgres"]) + } +} + +func TestInvalidPackageType(t *testing.T) { + resp, err := http.Post(apiURL("/api/v2/remotes"), "application/json", + strings.NewReader(`{"name":"bad","package_type":"bogus","base_url":"https://x.com"}`)) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("expected 400 for invalid package type, got %d", resp.StatusCode) + } +} diff --git a/e2e/proxy_test.go b/e2e/proxy_test.go new file mode 100644 index 0000000..cc647a4 --- /dev/null +++ b/e2e/proxy_test.go @@ -0,0 +1,38 @@ +//go:build e2e + +package e2e + +import ( + "net/http" + "testing" +) + +func TestProxyUnknownRemote(t *testing.T) { + assertStatus(t, apiURL("/api/v1/remote/nonexistent/some/path"), http.StatusNotFound) +} + +func TestProxyBlocklist(t *testing.T) { + createRemote(t, `{ + "name": "blocklist-test", + "package_type": "generic", + "base_url": "https://example.com", + "blocklist": ["\\.exe$"], + "stale_on_error": true + }`) + defer deleteRemote(t, "blocklist-test") + + assertStatus(t, apiURL("/api/v1/remote/blocklist-test/malware.exe"), http.StatusForbidden) +} + +func TestProxyPatterns(t *testing.T) { + createRemote(t, `{ + "name": "patterns-test", + "package_type": "generic", + "base_url": "https://example.com", + "patterns": ["^releases/"], + "stale_on_error": true + }`) + defer deleteRemote(t, "patterns-test") + + assertStatus(t, apiURL("/api/v1/remote/patterns-test/uploads/file.tar.gz"), http.StatusForbidden) +} diff --git a/examples/conf.d-method/alpine.yaml b/examples/conf.d-method/alpine.yaml deleted file mode 100644 index 55f8c65..0000000 --- a/examples/conf.d-method/alpine.yaml +++ /dev/null @@ -1,10 +0,0 @@ -remotes: - alpine: - base_url: "https://dl-cdn.alpinelinux.org" - package: "alpine" - description: "Alpine Linux APK package repository" - immutable_patterns: - - ".*/x86_64/.*\\.apk$" - cache: - immutable_ttl: 0 - mutable_ttl: 7200 diff --git a/examples/conf.d-method/github.yaml b/examples/conf.d-method/github.yaml deleted file mode 100644 index 81ec2e2..0000000 --- a/examples/conf.d-method/github.yaml +++ /dev/null @@ -1,11 +0,0 @@ -remotes: - github: - base_url: "https://github.com" - 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 diff --git a/examples/conf.d-method/pypi.yaml b/examples/conf.d-method/pypi.yaml deleted file mode 100644 index 0950dc2..0000000 --- a/examples/conf.d-method/pypi.yaml +++ /dev/null @@ -1,16 +0,0 @@ -remotes: - pypi: - base_url: "https://files.pythonhosted.org" - 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 diff --git a/examples/single-file/remotes.yaml b/examples/single-file/remotes.yaml deleted file mode 100644 index f4aaa06..0000000 --- a/examples/single-file/remotes.yaml +++ /dev/null @@ -1,532 +0,0 @@ -# Example remotes configuration — copy and adapt for your environment. -# -# immutable_patterns: artifacts cached forever (e.g. release binaries, versioned tags). -# mutable_patterns: artifacts that expire after cache.mutable_ttl seconds and are -# re-fetched from upstream on next request (e.g. index files, -# branch archives). Defaults to the package-type built-ins when -# not set (APKINDEX, repomd.xml, Docker manifests, etc.). -# cache: -# immutable_ttl: TTL for immutable files (0 = forever, rarely needed to change). -# mutable_ttl: TTL in seconds for mutable files. Omit to use the default (3600). -# -# quarantine_new: Set to true to block immutable artifacts published within the last -# quarantine_days days. Requests return 404 until the quarantine period -# expires. Fails open when the publish date cannot be determined. -# quarantine_days: Number of days to quarantine newly published artifacts (requires -# quarantine_new: true). The upstream Last-Modified header is used as -# the publish date. -# -# WARNING: this file may contain credentials — do not commit real values. -# -# Global configuration -#s3: -# endpoint: "localhost:9000" -# access_key: "minioadmin" -# secret_key: "minioadmin" -# bucket: "artifacts" -# secure: false -# -#redis: -# url: "redis://localhost:6379/0" -# -#database: -# url: "postgresql://artifacts:artifacts123@localhost:5432/artifacts" -# -remotes: - github: - base_url: "https://github.com" - package: "generic" - description: "GitHub releases and files" - immutable_patterns: - - "gruntwork-io/terragrunt/.*terragrunt_linux_amd64.*" - - "lxc/incus/.*\\.tar\\.gz$" - - "prometheus/node_exporter/.*/node_exporter-.*\\.linux-amd64\\.tar\\.gz$" - - "VictoriaMetrics/VictoriaMetrics/.*/vmutils-linux-amd64-.*\\.tar\\.gz$" - - "VictoriaMetrics/VictoriaMetrics/.*/victoria-metrics-linux-amd64-.*-cluster\\.tar\\.gz$" - - "VictoriaMetrics/VictoriaMetrics/.*/victoria-logs-linux-amd64-.*\\.tar\\.gz$" - - "VictoriaMetrics/VictoriaMetrics/.*/vlutils-linux-amd64-.*\\.tar\\.gz$" - - "prometheus-community/bind_exporter/.*/bind_exporter-.*\\.linux-amd64\\.tar\\.gz$" - - "prometheus-community/pgbouncer_exporter/.*/pgbouncer_exporter-.*\\.linux-amd64\\.tar\\.gz$" - - "prometheus-community/postgres_exporter/.*/postgres_exporter-.*\\.linux-amd64\\.tar\\.gz$" - - "onedr0p/exportarr/.*/exportarr_.*_linux_amd64\\.tar\\.gz$" - - "tynany/frr_exporter/.*/frr_exporter-.*\\.linux-amd64\\.tar\\.gz$" - - "camptocamp/prometheus-puppetdb-exporter/.*/prometheus-puppetdb-exporter-.*\\.linux-amd64\\.tar\\.gz$" - - "grafana/jsonnet-language-server/.*/jsonnet-language-server_.*_linux_amd64$" - - "helmfile/helmfile/.*/helmfile_.*_linux_amd64\\.tar\\.gz$" - - "helmfile/vals/.*/vals_.*_linux_amd64\\.tar\\.gz$" - - "openbao/openbao-plugins/.*/openbao-plugin-secrets-consul_linux_amd64_.*\\.tar\\.gz$" - - "openbao/openbao-plugins/.*/openbao-plugin-secrets-nomad_linux_amd64_.*\\.tar\\.gz$" - - "apple/foundationdb/.*/libfdb_c\\.x86_64\\.so$" - - "stalwartlabs/stalwart/.*/stalwart-cli-x86_64-unknown-linux-gnu\\.tar\\.gz$" - - "stalwartlabs/stalwart/.*/stalwart-foundationdb-x86_64-unknown-linux-gnu\\.tar\\.gz$" - - "stalwartlabs/stalwart/.*/stalwart-x86_64-unknown-linux-gnu\\.tar\\.gz$" - cache: - immutable_ttl: 0 # Files cached indefinitely - mutable_ttl: 0 - - github-archive: - base_url: "https://github.com" - package: "generic" - description: "GitHub repository archive tarballs" - immutable_patterns: - # Tag archives are immutable — a tag never changes - - ".*/archive/refs/tags/.*\\.tar\\.gz$" - mutable_patterns: - # Branch archives can change on every push - - ".*/archive/refs/heads/main\\.tar\\.gz$" - - ".*/archive/refs/heads/master\\.tar\\.gz$" - # Before re-downloading an expired branch archive, check whether it has - # actually changed (304 Not Modified → just refresh the TTL, no transfer). - # Only applies to user-defined mutable_patterns, not package-type defaults. - check_mutable_updates: true - cache: - immutable_ttl: 0 # Tag archives cached indefinitely - mutable_ttl: 86400 # Branch archives refreshed after 1 day - - gitea-dl: - base_url: "https://dl.gitea.com" - package: "generic" - description: "Gitea download site" - immutable_patterns: - - "act_runner/.*/act_runner-.*-linux-amd64$" - cache: - immutable_ttl: 0 # Files cached indefinitely - mutable_ttl: 0 - - hashicorp-releases: - base_url: "https://releases.hashicorp.com" - package: "generic" - description: "HashiCorp product releases" - immutable_patterns: - - "terraform/.*terraform_.*_linux_amd64\\.zip$" - - "terraform/.*terraform_.*_windows_amd64\\.zip$" - - "terraform/.*terraform_.*_darwin_amd64\\.zip$" - - "vault/.*vault_.*_linux_amd64\\.zip$" - - "vault/.*vault_.*_windows_amd64\\.zip$" - - "vault/.*vault_.*_darwin_amd64\\.zip$" - - "consul-cni/.*/consul-cni_.*_linux_amd64\\.zip$" - - "consul/.*/consul_.*_linux_amd64\\.zip$" - - "nomad-autoscaler/.*/nomad-autoscaler_.*_linux_amd64\\.zip$" - - "nomad/.*/nomad_.*_linux_amd64\\.zip$" - - "packer/.*/packer_.*_linux_amd64\\.zip$" - cache: - immutable_ttl: 0 # Files cached indefinitely - mutable_ttl: 0 - - alpine: - base_url: "https://dl-cdn.alpinelinux.org" - package: "alpine" - description: "Alpine Linux APK package repository" - immutable_patterns: - - ".*/x86_64/.*\\.apk$" - # check_mutable_updates not set: APKINDEX.tar.gz is a package-type default - # and is always re-fetched on expiry — conditional checks are skipped for - # built-in mutable patterns regardless of this flag. - cache: - immutable_ttl: 0 # Files cached indefinitely - mutable_ttl: 7200 # Index files (APKINDEX.tar.gz) cached for 2 hours - - almalinux: - base_url: "https://gsl-syd.mm.fcix.net/almalinux" - package: "rpm" - description: "AlmaLinux RPM package repository" - immutable_patterns: - - ".*/x86_64/.*\\.rpm$" - - ".*/noarch/.*\\.rpm$" - - ".*/repodata/.*$" - - ".*\\.rpm$" # Allow all RPM files - # repomd.xml / repodata are package-type defaults — always re-fetched on - # expiry. check_mutable_updates would only apply to any custom - # mutable_patterns added here. - cache: - immutable_ttl: 0 # Files cached indefinitely - mutable_ttl: 7200 # Metadata files cached for 2 hours - - epel: - base_url: "http://mirror.aarnet.edu.au/pub/epel" - package: "rpm" - description: "EPEL (Extra Packages for Enterprise Linux)" - immutable_patterns: - - "8/Everything/x86_64/.*\\.rpm$" - - "9/Everything/x86_64/.*\\.rpm$" - - "10/Everything/x86_64/.*\\.rpm$" - - ".*/noarch/.*\\.rpm$" - - ".*/repodata/.*$" - cache: - immutable_ttl: 0 # Files cached indefinitely - mutable_ttl: 7200 # Metadata files cached for 2 hours - - fedora: - base_url: "https://gsl-syd.mm.fcix.net/fedora/linux" - package: "rpm" - description: "Fedora Linux RPM package repository" - immutable_patterns: - - "releases/.*/Everything/x86_64/.*\\.rpm$" - - "updates/.*/Everything/x86_64/.*\\.rpm$" - - "development/.*/Everything/x86_64/.*\\.rpm$" - - ".*/noarch/.*\\.rpm$" - - "updates/.*/Everything/x86_64/repodata/.*$" - cache: - immutable_ttl: 0 # Files cached indefinitely - mutable_ttl: 300 # Metadata files cached for 5 minutes - - ghcr: - base_url: "https://ghcr.io" - package: "docker" - description: "GitHub Container Registry" - # username: "your-github-username" - # password: "your-github-pat" # needs read:packages scope - # Docker manifest/tag-list patterns are package-type defaults — always - # re-fetched on expiry. check_mutable_updates only applies to any custom - # mutable_patterns you add (e.g. a metadata endpoint). - cache: - immutable_ttl: 0 - mutable_ttl: 300 - - dockerhub: - base_url: "https://registry-1.docker.io" - package: "docker" - description: "Docker Hub registry" - cache: - immutable_ttl: 0 - mutable_ttl: 300 - - pypi: - base_url: "https://files.pythonhosted.org" - package: "pypi" - description: "Python Package Index — simple index and package files via a single remote" - # simple/ requests are transparently fetched from pypi.org; package files come from - # files.pythonhosted.org (base_url). URLs in the simple index are rewritten to this remote. - check_mutable_updates: true - # 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_days: 3 - immutable_patterns: - - "packages/.*\\.whl$" - - "packages/.*\\.whl\\.metadata$" - - "packages/.*\\.tar\\.gz$" - - "packages/.*\\.zip$" - - "packages/.*\\.egg$" - cache: - immutable_ttl: 0 - mutable_ttl: 600 # Simple index pages refreshed after 10 minutes - - pypi-gitea: - base_url: "https://gitea.example.com/api/packages/myorg/pypi" - package: "pypi" - description: "Private Gitea PyPI registry — simple index and files at the same host" - # username: "your-gitea-username" - # password: "your-personal-access-token" # needs package:read scope - check_mutable_updates: true - immutable_patterns: - - "files/.*\\.whl$" - - "files/.*\\.whl\\.metadata$" - - "files/.*\\.tar\\.gz$" - - "files/.*\\.zip$" - - "files/.*\\.egg$" - cache: - immutable_ttl: 0 - mutable_ttl: 600 - - npm: - base_url: "https://registry.npmjs.org" - package: "npm" - description: "npm registry — package metadata with tarball URL rewriting" - check_mutable_updates: true - immutable_patterns: - - \.tgz$ - mutable_patterns: - - ^(?!.*\.tgz$).* - cache: - immutable_ttl: 0 - mutable_ttl: 600 # Package metadata refreshed after 10 minutes - - hashicorp-helm: - base_url: "https://helm.releases.hashicorp.com" - package: "helm" - description: "HashiCorp Helm chart repository (Vault, Consul, Nomad, etc.)" - check_mutable_updates: true - immutable_patterns: - - "\\.tgz$" - cache: - immutable_ttl: 0 # Chart tarballs are versioned — cache forever - mutable_ttl: 3600 # index.yaml refreshed after 1 hour - - metallb: - base_url: "https://metallb.github.io/metallb" - package: "helm" - description: "MetalLB load balancer Helm charts" - check_mutable_updates: true - immutable_patterns: - - "\\.tgz$" - cache: - immutable_ttl: 0 - mutable_ttl: 3600 - - jetstack: - base_url: "https://charts.jetstack.io" - package: "helm" - description: "Jetstack Helm charts (cert-manager)" - check_mutable_updates: true - immutable_patterns: - - "\\.tgz$" - cache: - immutable_ttl: 0 - mutable_ttl: 3600 - - rancher-stable: - base_url: "https://releases.rancher.com/server-charts/stable" - package: "helm" - description: "Rancher stable Helm charts" - check_mutable_updates: true - immutable_patterns: - - "\\.tgz$" - cache: - immutable_ttl: 0 - mutable_ttl: 3600 - - purelb: - base_url: "https://gitlab.com/api/v4/projects/20400619/packages/helm/stable" - package: "helm" - description: "PureLB load balancer Helm charts" - check_mutable_updates: true - immutable_patterns: - - "\\.tgz$" - cache: - immutable_ttl: 0 - mutable_ttl: 3600 - - istio: - base_url: "https://istio-release.storage.googleapis.com/charts" - package: "helm" - description: "Istio service mesh Helm charts" - check_mutable_updates: true - immutable_patterns: - - "\\.tgz$" - cache: - immutable_ttl: 0 - mutable_ttl: 3600 - - cnpg: - base_url: "https://cloudnative-pg.github.io/charts" - package: "helm" - description: "CloudNativePG operator Helm charts" - check_mutable_updates: true - immutable_patterns: - - "\\.tgz$" - cache: - immutable_ttl: 0 - mutable_ttl: 3600 - - ceph-csi: - base_url: "https://ceph.github.io/csi-charts" - package: "helm" - description: "Ceph CSI driver Helm charts" - check_mutable_updates: true - immutable_patterns: - - "\\.tgz$" - cache: - immutable_ttl: 0 - mutable_ttl: 3600 - - external-dns: - base_url: "https://kubernetes-sigs.github.io/external-dns/" - package: "helm" - description: "ExternalDNS Helm charts" - check_mutable_updates: true - immutable_patterns: - - "\\.tgz$" - cache: - immutable_ttl: 0 - mutable_ttl: 3600 - - intel-helm: - base_url: "https://intel.github.io/helm-charts/" - package: "helm" - description: "Intel Helm charts" - check_mutable_updates: true - immutable_patterns: - - "\\.tgz$" - cache: - immutable_ttl: 0 - mutable_ttl: 3600 - - elastic: - base_url: "https://helm.elastic.co" - package: "helm" - description: "Elastic stack Helm charts" - check_mutable_updates: true - immutable_patterns: - - "\\.tgz$" - cache: - immutable_ttl: 0 - mutable_ttl: 3600 - - k8up-io: - base_url: "https://k8up-io.github.io/k8up" - package: "helm" - description: "K8up backup operator Helm charts" - check_mutable_updates: true - immutable_patterns: - - "\\.tgz$" - cache: - immutable_ttl: 0 - mutable_ttl: 3600 - - victoriametrics: - base_url: "https://victoriametrics.github.io/helm-charts/" - package: "helm" - description: "VictoriaMetrics observability Helm charts" - check_mutable_updates: true - immutable_patterns: - - "\\.tgz$" - cache: - immutable_ttl: 0 - mutable_ttl: 3600 - - grafana: - base_url: "https://grafana.github.io/helm-charts" - package: "helm" - description: "Grafana observability Helm charts" - check_mutable_updates: true - immutable_patterns: - - "\\.tgz$" - cache: - immutable_ttl: 0 - mutable_ttl: 3600 - - helm-openldap: - base_url: "https://jp-gouin.github.io/helm-openldap/" - package: "helm" - description: "OpenLDAP Helm charts" - check_mutable_updates: true - immutable_patterns: - - "\\.tgz$" - cache: - immutable_ttl: 0 - mutable_ttl: 3600 - - woodpecker: - base_url: "https://woodpecker-ci.org/" - package: "helm" - description: "Woodpecker CI Helm charts" - check_mutable_updates: true - immutable_patterns: - - "\\.tgz$" - cache: - immutable_ttl: 0 - mutable_ttl: 3600 - - stakater: - base_url: "https://stakater.github.io/stakater-charts" - package: "helm" - description: "Stakater Helm charts" - check_mutable_updates: true - immutable_patterns: - - "\\.tgz$" - cache: - immutable_ttl: 0 - mutable_ttl: 3600 - - jfrog: - base_url: "https://charts.jfrog.io/" - package: "helm" - description: "JFrog Helm charts" - check_mutable_updates: true - immutable_patterns: - - "\\.tgz$" - cache: - immutable_ttl: 0 - mutable_ttl: 3600 - - openvox: - base_url: "https://openvoxproject.github.io/openvox-helm-chart" - package: "helm" - description: "OpenVox Helm charts" - check_mutable_updates: true - immutable_patterns: - - "\\.tgz$" - cache: - immutable_ttl: 0 - mutable_ttl: 3600 - - puppet-forge: - base_url: "https://forgeapi.puppet.com" - package: "puppet" - description: "Puppet Forge module registry" - # Module metadata (v3/modules/, v3/releases) is mutable by default. - # Configure r10k / librarian-puppet with this remote as the Forge URL: - # http://your-proxy/api/v1/remote/puppet-forge - check_mutable_updates: true - immutable_patterns: - - "^v3/files/.*\\.tar\\.gz$" - cache: - immutable_ttl: 0 # Module tarballs cached indefinitely - mutable_ttl: 600 # Module metadata refreshed after 10 minutes - - terraform-registry: - base_url: "https://registry.terraform.io" - package: "terraform" - description: "Terraform/OpenTofu provider registry (Registry Protocol)" - # Provider version lists are mutable by default. - # Point Terraform at this remote via .terraformrc: - # host "registry.terraform.io" { - # services = { - # "providers.v1" = "http://your-proxy/api/v1/remote/terraform-registry/" - # } - # } - # releases_remote must match the name of the hashicorp-releases remote below, - # so download_url / shasums_url in per-version download info are rewritten. - releases_remote: "hashicorp-releases" - immutable_patterns: - - "[^/]+/[^/]+/[^/]+/download/[^/]+/[^/]+$" - cache: - immutable_ttl: 0 # Per-version download info cached indefinitely - mutable_ttl: 300 # Provider versions list refreshed after 5 minutes - - hashicorp-releases: - base_url: "https://releases.hashicorp.com" - package: "generic" - description: "HashiCorp releases CDN — provider zips, SHA256SUMS, and signatures" - immutable_patterns: - - ".*\\.zip$" - - ".*SHA256SUMS(\\.sig)?$" - cache: - immutable_ttl: 0 # Release artifacts cached indefinitely - mutable_ttl: 0 - - -virtuals: - helm-all: - package: "helm" - description: "Virtual repository merging all helm remotes — member order is priority order for duplicate chart+version" - members: - - hashicorp-helm - - metallb - - jetstack - - rancher-stable - - purelb - - istio - - cnpg - - ceph-csi - - external-dns - - intel-helm - - elastic - - k8up-io - - victoriametrics - - grafana - - helm-openldap - - woodpecker - - stakater - - jfrog - - openvox - -locals: - local-generic: - package: "generic" - description: "Local generic file repository" - cache: - immutable_ttl: 0 # Files cached indefinitely - mutable_ttl: 0 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..fd7c1ea --- /dev/null +++ b/go.mod @@ -0,0 +1,104 @@ +module git.unkin.net/unkin/artifactapi + +go 1.25.9 + +require ( + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/go-chi/chi/v5 v5.3.0 + github.com/jackc/pgx/v5 v5.10.0 + github.com/minio/minio-go/v7 v7.2.0 + github.com/redis/go-redis/v9 v9.20.0 + github.com/testcontainers/testcontainers-go v0.42.0 + github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 + github.com/testcontainers/testcontainers-go/modules/redis v0.42.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + dario.cat/mergo v1.0.2 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/x/ansi v0.11.7 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/ebitengine/purego v0.10.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/klauspost/compress v1.18.6 // indirect + github.com/klauspost/cpuid/v2 v2.2.11 // indirect + github.com/klauspost/crc32 v1.3.0 // indirect + github.com/lucasb-eyer/go-colorful v1.4.0 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/magiconair/properties v1.8.10 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.23 // indirect + github.com/mdelapenya/tlscert v0.2.0 // indirect + github.com/minio/crc64nvme v1.1.1 // indirect + github.com/minio/md5-simd v1.1.2 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.2.0 // indirect + github.com/moby/moby/api v1.54.1 // indirect + github.com/moby/moby/client v0.4.0 // indirect + github.com/moby/patternmatcher v0.6.1 // indirect + github.com/moby/sys/sequential v0.6.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/philhofer/fwd v1.2.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/rs/xid v1.6.0 // indirect + github.com/shirou/gopsutil/v4 v4.26.3 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect + github.com/stretchr/testify v1.11.1 // indirect + github.com/tinylib/msgp v1.6.1 // indirect + github.com/tklauser/go-sysconf v0.3.16 // indirect + github.com/tklauser/numcpus v0.11.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + github.com/zeebo/xxh3 v1.1.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect + go.opentelemetry.io/otel v1.41.0 // indirect + go.opentelemetry.io/otel/metric v1.41.0 // indirect + go.opentelemetry.io/otel/trace v1.41.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.51.0 // indirect + golang.org/x/net v0.53.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.44.0 // indirect + golang.org/x/text v0.37.0 // indirect + gopkg.in/ini.v1 v1.67.2 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e31476b --- /dev/null +++ b/go.sum @@ -0,0 +1,247 @@ +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +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/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +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/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= +github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= +github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= +github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU= +github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM= +github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= +github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= +github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= +github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= +github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI= +github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= +github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= +github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= +github.com/minio/minio-go/v7 v7.2.0 h1:RCJM0R1XOsRs+A3x3UCaf3ZYbByDaLjFeAi+YCQEPhs= +github.com/minio/minio-go/v7 v7.2.0/go.mod h1:EU9hENAStx/xXduNdrGO5e4X5vk19NtgB+RIPjZO8o0= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= +github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= +github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4= +github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw= +github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g= +github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= +github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/redis/go-redis/v9 v9.20.0 h1:WnQYxLkgO2xiXTCJY0ldIiI8dNqCDlQAG+AtaH7a2a0= +github.com/redis/go-redis/v9 v9.20.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc= +github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY= +github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30= +github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 h1:GCbb1ndrF7OTDiIvxXyItaDab4qkzTFJ48LKFdM7EIo= +github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0/go.mod h1:IRPBaI8jXdrNfD0e4Zm7Fbcgaz5shKxOQv4axiL09xs= +github.com/testcontainers/testcontainers-go/modules/redis v0.42.0 h1:id/6LH8ZeDrtAUVSuNvZUAJ1kVpb82y1pr9yweAWsRg= +github.com/testcontainers/testcontainers-go/modules/redis v0.42.0/go.mod h1:uF0jI8FITagQpBNOgweGBmPf6rP4K0SeL1XFPbsZSSY= +github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY= +github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= +go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= +go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= +go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= +go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= +go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= +go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= +go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= +go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= +go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= +go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/ini.v1 v1.67.2 h1:JtOSMb9OuaCZKr7h5D/h6iii14sK0hLbplTc6frx4Ss= +gopkg.in/ini.v1 v1.67.2/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= diff --git a/internal/api/v1/proxy.go b/internal/api/v1/proxy.go new file mode 100644 index 0000000..e1d3e1e --- /dev/null +++ b/internal/api/v1/proxy.go @@ -0,0 +1,106 @@ +package v1 + +import ( + "errors" + "fmt" + "io" + "log/slog" + "net/http" + + "github.com/go-chi/chi/v5" + + "git.unkin.net/unkin/artifactapi/internal/database" + "git.unkin.net/unkin/artifactapi/internal/provider" + "git.unkin.net/unkin/artifactapi/internal/proxy" + "git.unkin.net/unkin/artifactapi/internal/virtual" +) + +type ProxyHandler struct { + engine *proxy.Engine + virtualEngine *virtual.Engine + db *database.DB +} + +func NewProxyHandler(engine *proxy.Engine, virtualEngine *virtual.Engine, db *database.DB) *ProxyHandler { + return &ProxyHandler{engine: engine, virtualEngine: virtualEngine, db: db} +} + +func (h *ProxyHandler) Routes() chi.Router { + r := chi.NewRouter() + r.Get("/remote/{remoteName}/*", h.handleProxy) + r.Get("/virtual/{virtualName}/*", h.handleVirtual) + return r +} + +func (h *ProxyHandler) handleProxy(w http.ResponseWriter, r *http.Request) { + remoteName := chi.URLParam(r, "remoteName") + path := chi.URLParam(r, "*") + + remote, err := h.db.GetRemote(r.Context(), remoteName) + if err != nil { + http.Error(w, fmt.Sprintf("remote %q not found", remoteName), http.StatusNotFound) + return + } + + prov, err := provider.Get(remote.PackageType) + if err != nil { + http.Error(w, fmt.Sprintf("no provider for %q", remote.PackageType), http.StatusInternalServerError) + return + } + + result, err := h.engine.Fetch(r.Context(), *remote, path, prov) + if err != nil { + var proxyErr *proxy.ProxyError + if errors.As(err, &proxyErr) { + http.Error(w, proxyErr.Message, proxyErr.Status) + return + } + slog.Error("proxy fetch failed", "remote", remoteName, "path", path, "error", err) + http.Error(w, "bad gateway", http.StatusBadGateway) + return + } + defer result.Reader.Close() + + w.Header().Set("Content-Type", result.ContentType) + w.Header().Set("X-Artifact-Source", result.Source) + if result.Size > 0 { + w.Header().Set("X-Artifact-Size", fmt.Sprintf("%d", result.Size)) + } + w.WriteHeader(http.StatusOK) + io.Copy(w, result.Reader) +} + +func (h *ProxyHandler) handleVirtual(w http.ResponseWriter, r *http.Request) { + virtualName := chi.URLParam(r, "virtualName") + path := chi.URLParam(r, "*") + + virt, err := h.db.GetVirtual(r.Context(), virtualName) + if err != nil { + http.Error(w, fmt.Sprintf("virtual %q not found", virtualName), http.StatusNotFound) + return + } + + proxyBaseURL := fmt.Sprintf("%s://%s", scheme(r), r.Host) + + body, contentType, err := h.virtualEngine.Fetch(r.Context(), *virt, path, proxyBaseURL) + if err != nil { + slog.Error("virtual fetch failed", "virtual", virtualName, "path", path, "error", err) + http.Error(w, "bad gateway", http.StatusBadGateway) + return + } + + w.Header().Set("Content-Type", contentType) + w.Header().Set("X-Artifact-Source", "virtual") + w.WriteHeader(http.StatusOK) + w.Write(body) +} + +func scheme(r *http.Request) string { + if r.TLS != nil { + return "https" + } + if fwd := r.Header.Get("X-Forwarded-Proto"); fwd != "" { + return fwd + } + return "http" +} diff --git a/internal/api/v2/events.go b/internal/api/v2/events.go new file mode 100644 index 0000000..1d12a81 --- /dev/null +++ b/internal/api/v2/events.go @@ -0,0 +1,49 @@ +package v2 + +import ( + "fmt" + "net/http" + "time" + + "github.com/go-chi/chi/v5" +) + +type EventsHandler struct{} + +func NewEventsHandler() *EventsHandler { + return &EventsHandler{} +} + +func (h *EventsHandler) Routes() chi.Router { + r := chi.NewRouter() + r.Get("/", h.stream) + return r +} + +func (h *EventsHandler) stream(w http.ResponseWriter, r *http.Request) { + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming not supported", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") + w.WriteHeader(http.StatusOK) + flusher.Flush() + + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + for { + select { + case <-r.Context().Done(): + return + case <-ticker.C: + fmt.Fprintf(w, ": keepalive\n\n") + flusher.Flush() + } + } +} diff --git a/internal/api/v2/health.go b/internal/api/v2/health.go new file mode 100644 index 0000000..dbf94af --- /dev/null +++ b/internal/api/v2/health.go @@ -0,0 +1,43 @@ +package v2 + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + + "git.unkin.net/unkin/artifactapi/internal/cache" + "git.unkin.net/unkin/artifactapi/internal/database" + "git.unkin.net/unkin/artifactapi/internal/storage" +) + +type HealthHandler struct { + db *database.DB + cache *cache.Redis + store *storage.S3 +} + +func NewHealthHandler(db *database.DB, c *cache.Redis, s *storage.S3) *HealthHandler { + return &HealthHandler{db: db, cache: c, store: s} +} + +func (h *HealthHandler) Routes() chi.Router { + r := chi.NewRouter() + r.Get("/", h.health) + return r +} + +func (h *HealthHandler) health(w http.ResponseWriter, r *http.Request) { + status := map[string]string{ + "status": "ok", + "postgres": "ok", + "redis": "ok", + "s3": "ok", + } + + if err := h.db.Pool.Ping(r.Context()); err != nil { + status["postgres"] = "error" + status["status"] = "degraded" + } + + writeJSON(w, http.StatusOK, status) +} diff --git a/internal/api/v2/objects.go b/internal/api/v2/objects.go new file mode 100644 index 0000000..c8f02c4 --- /dev/null +++ b/internal/api/v2/objects.go @@ -0,0 +1,57 @@ +package v2 + +import ( + "fmt" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + + "git.unkin.net/unkin/artifactapi/internal/database" +) + +type ObjectsHandler struct { + db *database.DB +} + +func NewObjectsHandler(db *database.DB) *ObjectsHandler { + return &ObjectsHandler{db: db} +} + +func (h *ObjectsHandler) Routes() chi.Router { + r := chi.NewRouter() + r.Get("/", h.list) + r.Delete("/*", h.evict) + return r +} + +func (h *ObjectsHandler) list(w http.ResponseWriter, r *http.Request) { + remoteName := chi.URLParam(r, "name") + limit, _ := strconv.Atoi(r.URL.Query().Get("per_page")) + if limit <= 0 || limit > 100 { + limit = 50 + } + page, _ := strconv.Atoi(r.URL.Query().Get("page")) + if page <= 0 { + page = 1 + } + offset := (page - 1) * limit + + artifacts, err := h.db.ListArtifacts(r.Context(), remoteName, limit, offset) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, artifacts) +} + +func (h *ObjectsHandler) evict(w http.ResponseWriter, r *http.Request) { + remoteName := chi.URLParam(r, "name") + path := chi.URLParam(r, "*") + + if err := h.db.DeleteArtifact(r.Context(), remoteName, path); err != nil { + http.Error(w, fmt.Sprintf("evict failed: %v", err), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/v2/probe.go b/internal/api/v2/probe.go new file mode 100644 index 0000000..a9a2faa --- /dev/null +++ b/internal/api/v2/probe.go @@ -0,0 +1,109 @@ +package v2 + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + + "git.unkin.net/unkin/artifactapi/internal/database" + "git.unkin.net/unkin/artifactapi/internal/provider" + "git.unkin.net/unkin/artifactapi/internal/proxy" +) + +type ProbeHandler struct { + engine *proxy.Engine + db *database.DB +} + +func NewProbeHandler(engine *proxy.Engine, db *database.DB) *ProbeHandler { + return &ProbeHandler{engine: engine, db: db} +} + +func (h *ProbeHandler) Routes() chi.Router { + r := chi.NewRouter() + r.Post("/", h.probe) + return r +} + +type probeRequest struct { + Remote string `json:"remote"` + Path string `json:"path"` +} + +type probeResponse struct { + Status int `json:"status"` + Source string `json:"source,omitempty"` + ContentType string `json:"content_type,omitempty"` + SizeBytes int64 `json:"size_bytes"` + Headers map[string]string `json:"headers,omitempty"` + DurationMS int64 `json:"duration_ms"` + Error string `json:"error,omitempty"` +} + +func (h *ProbeHandler) probe(w http.ResponseWriter, r *http.Request) { + var req probeRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + + if req.Remote == "" || req.Path == "" { + http.Error(w, "remote and path are required", http.StatusBadRequest) + return + } + + remote, err := h.db.GetRemote(r.Context(), req.Remote) + if err != nil { + writeJSON(w, http.StatusOK, probeResponse{ + Status: 404, + Error: fmt.Sprintf("remote %q not found", req.Remote), + }) + return + } + + prov, err := provider.Get(remote.PackageType) + if err != nil { + writeJSON(w, http.StatusOK, probeResponse{ + Status: 500, + Error: fmt.Sprintf("no provider for %q", remote.PackageType), + }) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + + start := time.Now() + result, err := h.engine.Fetch(ctx, *remote, req.Path, prov) + duration := time.Since(start).Milliseconds() + + if err != nil { + writeJSON(w, http.StatusOK, probeResponse{ + Status: 502, + DurationMS: duration, + Error: err.Error(), + }) + return + } + + io.Copy(io.Discard, result.Reader) + result.Reader.Close() + + writeJSON(w, http.StatusOK, probeResponse{ + Status: 200, + Source: result.Source, + ContentType: result.ContentType, + SizeBytes: result.Size, + DurationMS: duration, + Headers: map[string]string{ + "X-Artifact-Source": result.Source, + "X-Artifact-Size": fmt.Sprintf("%d", result.Size), + "Content-Type": result.ContentType, + }, + }) +} diff --git a/internal/api/v2/remotes.go b/internal/api/v2/remotes.go new file mode 100644 index 0000000..6e767da --- /dev/null +++ b/internal/api/v2/remotes.go @@ -0,0 +1,96 @@ +package v2 + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/go-chi/chi/v5" + + "git.unkin.net/unkin/artifactapi/internal/database" + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +type RemotesHandler struct { + db *database.DB +} + +func NewRemotesHandler(db *database.DB) *RemotesHandler { + return &RemotesHandler{db: db} +} + +func (h *RemotesHandler) Routes() chi.Router { + r := chi.NewRouter() + r.Get("/", h.list) + r.Post("/", h.create) + r.Get("/{name}", h.get) + r.Put("/{name}", h.update) + r.Delete("/{name}", h.del) + return r +} + +func (h *RemotesHandler) list(w http.ResponseWriter, r *http.Request) { + remotes, err := h.db.ListRemotes(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, remotes) +} + +func (h *RemotesHandler) get(w http.ResponseWriter, r *http.Request) { + name := chi.URLParam(r, "name") + remote, err := h.db.GetRemote(r.Context(), name) + if err != nil { + http.Error(w, fmt.Sprintf("remote %q not found", name), http.StatusNotFound) + return + } + writeJSON(w, http.StatusOK, remote) +} + +func (h *RemotesHandler) create(w http.ResponseWriter, r *http.Request) { + var remote models.Remote + if err := json.NewDecoder(r.Body).Decode(&remote); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + if !remote.PackageType.Valid() { + http.Error(w, fmt.Sprintf("invalid package type: %q", remote.PackageType), http.StatusBadRequest) + return + } + if err := h.db.CreateRemote(r.Context(), &remote); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusCreated, remote) +} + +func (h *RemotesHandler) update(w http.ResponseWriter, r *http.Request) { + name := chi.URLParam(r, "name") + var remote models.Remote + if err := json.NewDecoder(r.Body).Decode(&remote); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + remote.Name = name + if err := h.db.UpdateRemote(r.Context(), &remote); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, remote) +} + +func (h *RemotesHandler) del(w http.ResponseWriter, r *http.Request) { + name := chi.URLParam(r, "name") + if err := h.db.DeleteRemote(r.Context(), name); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(v) +} diff --git a/internal/api/v2/stats.go b/internal/api/v2/stats.go new file mode 100644 index 0000000..06fe7df --- /dev/null +++ b/internal/api/v2/stats.go @@ -0,0 +1,42 @@ +package v2 + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + + "git.unkin.net/unkin/artifactapi/internal/database" +) + +type StatsHandler struct { + db *database.DB +} + +func NewStatsHandler(db *database.DB) *StatsHandler { + return &StatsHandler{db: db} +} + +func (h *StatsHandler) Routes() chi.Router { + r := chi.NewRouter() + r.Get("/", h.overview) + r.Get("/top-remotes", h.topRemotes) + return r +} + +func (h *StatsHandler) overview(w http.ResponseWriter, r *http.Request) { + stats, err := h.db.GetOverviewStats(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, stats) +} + +func (h *StatsHandler) topRemotes(w http.ResponseWriter, r *http.Request) { + remotes, err := h.db.GetTopRemotes(r.Context(), 10) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, remotes) +} diff --git a/internal/api/v2/virtuals.go b/internal/api/v2/virtuals.go new file mode 100644 index 0000000..98d3ad7 --- /dev/null +++ b/internal/api/v2/virtuals.go @@ -0,0 +1,86 @@ +package v2 + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/go-chi/chi/v5" + + "git.unkin.net/unkin/artifactapi/internal/database" + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +type VirtualsHandler struct { + db *database.DB +} + +func NewVirtualsHandler(db *database.DB) *VirtualsHandler { + return &VirtualsHandler{db: db} +} + +func (h *VirtualsHandler) Routes() chi.Router { + r := chi.NewRouter() + r.Get("/", h.list) + r.Post("/", h.create) + r.Get("/{name}", h.get) + r.Put("/{name}", h.update) + r.Delete("/{name}", h.del) + return r +} + +func (h *VirtualsHandler) list(w http.ResponseWriter, r *http.Request) { + virtuals, err := h.db.ListVirtuals(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, virtuals) +} + +func (h *VirtualsHandler) get(w http.ResponseWriter, r *http.Request) { + name := chi.URLParam(r, "name") + virt, err := h.db.GetVirtual(r.Context(), name) + if err != nil { + http.Error(w, fmt.Sprintf("virtual %q not found", name), http.StatusNotFound) + return + } + writeJSON(w, http.StatusOK, virt) +} + +func (h *VirtualsHandler) create(w http.ResponseWriter, r *http.Request) { + var virt models.Virtual + if err := json.NewDecoder(r.Body).Decode(&virt); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + if err := h.db.CreateVirtual(r.Context(), &virt); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusCreated, virt) +} + +func (h *VirtualsHandler) update(w http.ResponseWriter, r *http.Request) { + name := chi.URLParam(r, "name") + var virt models.Virtual + if err := json.NewDecoder(r.Body).Decode(&virt); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + virt.Name = name + if err := h.db.UpdateVirtual(r.Context(), &virt); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, virt) +} + +func (h *VirtualsHandler) del(w http.ResponseWriter, r *http.Request) { + name := chi.URLParam(r, "name") + if err := h.db.DeleteVirtual(r.Context(), name); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/auth/basic.go b/internal/auth/basic.go new file mode 100644 index 0000000..47ab1b0 --- /dev/null +++ b/internal/auth/basic.go @@ -0,0 +1,18 @@ +package auth + +import ( + "encoding/base64" + "net/http" + + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +func BasicHeaders(remote models.Remote) http.Header { + h := http.Header{} + if remote.Username != "" { + h.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString( + []byte(remote.Username+":"+remote.Password), + )) + } + return h +} diff --git a/internal/cache/redis.go b/internal/cache/redis.go new file mode 100644 index 0000000..3e92b64 --- /dev/null +++ b/internal/cache/redis.go @@ -0,0 +1,105 @@ +package cache + +import ( + "context" + "fmt" + "time" + + "github.com/redis/go-redis/v9" +) + +type Redis struct { + client *redis.Client +} + +func NewRedis(url string) (*Redis, error) { + opts, err := redis.ParseURL(url) + if err != nil { + return nil, fmt.Errorf("parse redis url: %w", err) + } + + client := redis.NewClient(opts) + + if err := client.Ping(context.Background()).Err(); err != nil { + return nil, fmt.Errorf("ping redis: %w", err) + } + + return &Redis{client: client}, nil +} + +func (r *Redis) Close() error { + return r.client.Close() +} + +func (r *Redis) SetTTL(ctx context.Context, remote, path string, ttl time.Duration) error { + key := fmt.Sprintf("ttl:%s:%s", remote, path) + return r.client.Set(ctx, key, "1", ttl).Err() +} + +func (r *Redis) CheckTTL(ctx context.Context, remote, path string) (bool, error) { + key := fmt.Sprintf("ttl:%s:%s", remote, path) + exists, err := r.client.Exists(ctx, key).Result() + if err != nil { + return false, err + } + return exists > 0, nil +} + +func (r *Redis) AcquireLock(ctx context.Context, remote, path string, ttl time.Duration) (bool, error) { + key := fmt.Sprintf("lock:%s:%s", remote, path) + ok, err := r.client.SetNX(ctx, key, "1", ttl).Result() + return ok, err +} + +func (r *Redis) ReleaseLock(ctx context.Context, remote, path string) error { + key := fmt.Sprintf("lock:%s:%s", remote, path) + return r.client.Del(ctx, key).Err() +} + +func (r *Redis) SetETag(ctx context.Context, remote, path, etag string, ttl time.Duration) error { + key := fmt.Sprintf("etag:%s:%s", remote, path) + return r.client.Set(ctx, key, etag, ttl).Err() +} + +func (r *Redis) GetETag(ctx context.Context, remote, path string) (string, error) { + key := fmt.Sprintf("etag:%s:%s", remote, path) + val, err := r.client.Get(ctx, key).Result() + if err == redis.Nil { + return "", nil + } + return val, err +} + +func (r *Redis) IncrCircuitFailure(ctx context.Context, remote string, cooldown time.Duration) (int64, error) { + key := fmt.Sprintf("circuit:%s", remote) + pipe := r.client.Pipeline() + incr := pipe.Incr(ctx, key) + pipe.Expire(ctx, key, cooldown) + _, err := pipe.Exec(ctx) + if err != nil { + return 0, err + } + return incr.Val(), nil +} + +func (r *Redis) ResetCircuit(ctx context.Context, remote string) error { + key := fmt.Sprintf("circuit:%s", remote) + return r.client.Del(ctx, key).Err() +} + +func (r *Redis) GetCircuitFailures(ctx context.Context, remote string) (int64, error) { + key := fmt.Sprintf("circuit:%s", remote) + val, err := r.client.Get(ctx, key).Int64() + if err == redis.Nil { + return 0, nil + } + return val, err +} + +func (r *Redis) FlushRemote(ctx context.Context, remote string) error { + iter := r.client.Scan(ctx, 0, fmt.Sprintf("*:%s:*", remote), 100).Iterator() + for iter.Next(ctx) { + r.client.Del(ctx, iter.Val()) + } + return iter.Err() +} diff --git a/internal/config/env.go b/internal/config/env.go new file mode 100644 index 0000000..633beba --- /dev/null +++ b/internal/config/env.go @@ -0,0 +1,72 @@ +package config + +import ( + "fmt" + "os" + "strconv" +) + +type Config struct { + ListenAddr string + + DBHost string + DBPort int + DBUser string + DBPass string + DBName string + DBSSL string + + RedisURL string + + S3Endpoint string + S3AccessKey string + S3SecretKey string + S3Bucket string + S3Secure bool + S3Region string +} + +func (c *Config) DatabaseDSN() string { + return fmt.Sprintf( + "postgres://%s:%s@%s:%d/%s?sslmode=%s", + c.DBUser, c.DBPass, c.DBHost, c.DBPort, c.DBName, c.DBSSL, + ) +} + +func Load() (*Config, error) { + dbPort, err := strconv.Atoi(getenv("DBPORT", "5432")) + if err != nil { + return nil, fmt.Errorf("invalid DBPORT: %w", err) + } + + s3Secure, _ := strconv.ParseBool(getenv("MINIO_SECURE", "false")) + + cfg := &Config{ + ListenAddr: getenv("LISTEN_ADDR", ":8000"), + + DBHost: getenv("DBHOST", "localhost"), + DBPort: dbPort, + DBUser: getenv("DBUSER", "artifacts"), + DBPass: getenv("DBPASS", ""), + DBName: getenv("DBNAME", "artifacts"), + DBSSL: getenv("DBSSL", "disable"), + + RedisURL: getenv("REDIS_URL", "redis://localhost:6379"), + + S3Endpoint: getenv("MINIO_ENDPOINT", "localhost:9000"), + S3AccessKey: getenv("MINIO_ACCESS_KEY", ""), + S3SecretKey: getenv("MINIO_SECRET_KEY", ""), + S3Bucket: getenv("MINIO_BUCKET", "artifacts"), + S3Secure: s3Secure, + S3Region: getenv("MINIO_REGION", ""), + } + + return cfg, nil +} + +func getenv(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/internal/database/artifacts.go b/internal/database/artifacts.go new file mode 100644 index 0000000..fcd6e21 --- /dev/null +++ b/internal/database/artifacts.go @@ -0,0 +1,153 @@ +package database + +import ( + "context" + "time" + + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +func (db *DB) UpsertBlob(ctx context.Context, contentHash, s3Key string, sizeBytes int64, contentType string) error { + _, err := db.Pool.Exec(ctx, ` + INSERT INTO blobs (content_hash, s3_key, size_bytes, content_type) + VALUES ($1, $2, $3, $4) + ON CONFLICT (content_hash) DO NOTHING + `, contentHash, s3Key, sizeBytes, contentType) + return err +} + +func (db *DB) UpsertArtifact(ctx context.Context, remoteName, path, contentHash, upstreamETag string) error { + _, err := db.Pool.Exec(ctx, ` + INSERT INTO artifacts (remote_name, path, content_hash, upstream_etag) + VALUES ($1, $2, $3, $4) + ON CONFLICT (remote_name, path) DO UPDATE SET + content_hash = EXCLUDED.content_hash, + upstream_etag = EXCLUDED.upstream_etag, + last_fetched_at = NOW(), + fetch_count = artifacts.fetch_count + 1 + `, remoteName, path, contentHash, upstreamETag) + return err +} + +func (db *DB) GetArtifact(ctx context.Context, remoteName, path string) (*models.Artifact, error) { + row := db.Pool.QueryRow(ctx, ` + SELECT a.id, a.remote_name, a.path, a.content_hash, a.upstream_etag, + a.upstream_last_modified, a.first_seen_at, a.last_fetched_at, + a.last_accessed_at, a.fetch_count, a.access_count, + b.size_bytes, b.content_type + FROM artifacts a + JOIN blobs b ON a.content_hash = b.content_hash + WHERE a.remote_name = $1 AND a.path = $2 + `, remoteName, path) + + var a models.Artifact + err := row.Scan( + &a.ID, &a.RemoteName, &a.Path, &a.ContentHash, &a.UpstreamETag, + &a.UpstreamLastModified, &a.FirstSeenAt, &a.LastFetchedAt, + &a.LastAccessedAt, &a.FetchCount, &a.AccessCount, + &a.SizeBytes, &a.ContentType, + ) + if err != nil { + return nil, err + } + return &a, nil +} + +func (db *DB) TouchArtifactAccess(ctx context.Context, remoteName, path string) error { + _, err := db.Pool.Exec(ctx, ` + UPDATE artifacts SET + last_accessed_at = NOW(), + access_count = access_count + 1 + WHERE remote_name = $1 AND path = $2 + `, remoteName, path) + return err +} + +func (db *DB) ListArtifacts(ctx context.Context, remoteName string, limit, offset int) ([]models.Artifact, error) { + rows, err := db.Pool.Query(ctx, ` + SELECT a.id, a.remote_name, a.path, a.content_hash, a.upstream_etag, + a.upstream_last_modified, a.first_seen_at, a.last_fetched_at, + a.last_accessed_at, a.fetch_count, a.access_count, + b.size_bytes, b.content_type + FROM artifacts a + JOIN blobs b ON a.content_hash = b.content_hash + WHERE a.remote_name = $1 + ORDER BY a.path + LIMIT $2 OFFSET $3 + `, remoteName, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + + var artifacts []models.Artifact + for rows.Next() { + var a models.Artifact + if err := rows.Scan( + &a.ID, &a.RemoteName, &a.Path, &a.ContentHash, &a.UpstreamETag, + &a.UpstreamLastModified, &a.FirstSeenAt, &a.LastFetchedAt, + &a.LastAccessedAt, &a.FetchCount, &a.AccessCount, + &a.SizeBytes, &a.ContentType, + ); err != nil { + return nil, err + } + artifacts = append(artifacts, a) + } + return artifacts, rows.Err() +} + +func (db *DB) DeleteArtifact(ctx context.Context, remoteName, path string) error { + _, err := db.Pool.Exec(ctx, `DELETE FROM artifacts WHERE remote_name = $1 AND path = $2`, remoteName, path) + return err +} + +func (db *DB) InsertAccessLog(ctx context.Context, remoteName, path string, cacheHit bool, sizeBytes int64, upstreamMS int, clientIP string) error { + _, err := db.Pool.Exec(ctx, ` + INSERT INTO access_log (remote_name, path, cache_hit, size_bytes, upstream_ms, client_ip) + VALUES ($1, $2, $3, $4, $5, $6) + `, remoteName, path, cacheHit, sizeBytes, upstreamMS, clientIP) + return err +} + +func (db *DB) FindOrphanedBlobs(ctx context.Context) ([]models.Blob, error) { + rows, err := db.Pool.Query(ctx, ` + SELECT b.content_hash, b.s3_key, b.size_bytes, b.content_type, b.created_at + FROM blobs b + WHERE b.content_hash NOT IN ( + SELECT content_hash FROM artifacts + UNION + SELECT content_hash FROM local_files + ) + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var blobs []models.Blob + for rows.Next() { + var b models.Blob + if err := rows.Scan(&b.ContentHash, &b.S3Key, &b.SizeBytes, &b.ContentType, &b.CreatedAt); err != nil { + return nil, err + } + blobs = append(blobs, b) + } + return blobs, rows.Err() +} + +func (db *DB) DeleteBlob(ctx context.Context, contentHash string) error { + _, err := db.Pool.Exec(ctx, `DELETE FROM blobs WHERE content_hash = $1`, contentHash) + return err +} + +func (db *DB) DeleteColdArtifacts(ctx context.Context, remoteName string, olderThan time.Duration) (int64, error) { + cutoff := time.Now().Add(-olderThan) + tag, err := db.Pool.Exec(ctx, ` + DELETE FROM artifacts + WHERE remote_name = $1 AND last_accessed_at < $2 + `, remoteName, cutoff) + if err != nil { + return 0, err + } + return tag.RowsAffected(), nil +} diff --git a/internal/database/postgres.go b/internal/database/postgres.go new file mode 100644 index 0000000..94ea7be --- /dev/null +++ b/internal/database/postgres.go @@ -0,0 +1,126 @@ +package database + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" +) + +type DB struct { + Pool *pgxpool.Pool +} + +func New(dsn string) (*DB, error) { + pool, err := pgxpool.New(context.Background(), dsn) + if err != nil { + return nil, fmt.Errorf("connect to postgres: %w", err) + } + + if err := pool.Ping(context.Background()); err != nil { + pool.Close() + return nil, fmt.Errorf("ping postgres: %w", err) + } + + db := &DB{Pool: pool} + if err := db.migrate(); err != nil { + pool.Close() + return nil, fmt.Errorf("run migrations: %w", err) + } + + return db, nil +} + +func (db *DB) Close() { + db.Pool.Close() +} + +func (db *DB) migrate() error { + ctx := context.Background() + + _, err := db.Pool.Exec(ctx, ` + CREATE TABLE IF NOT EXISTS remotes ( + name TEXT PRIMARY KEY, + package_type TEXT NOT NULL, + base_url TEXT NOT NULL, + description TEXT DEFAULT '', + username TEXT DEFAULT '', + password TEXT DEFAULT '', + immutable_ttl INTEGER DEFAULT 0, + mutable_ttl INTEGER DEFAULT 3600, + check_mutable BOOLEAN DEFAULT TRUE, + patterns TEXT[] DEFAULT '{}', + blocklist TEXT[] DEFAULT '{}', + mutable_patterns TEXT[] DEFAULT '{}', + immutable_patterns TEXT[] DEFAULT '{}', + ban_tags_enabled BOOLEAN DEFAULT FALSE, + ban_tags TEXT[] DEFAULT '{}', + quarantine_enabled BOOLEAN DEFAULT FALSE, + quarantine_days INTEGER DEFAULT 3, + stale_on_error BOOLEAN DEFAULT TRUE, + releases_remote TEXT DEFAULT '', + managed_by TEXT DEFAULT '', + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() + ); + + CREATE TABLE IF NOT EXISTS virtuals ( + name TEXT PRIMARY KEY, + package_type TEXT NOT NULL, + description TEXT DEFAULT '', + members TEXT[] NOT NULL, + managed_by TEXT DEFAULT '', + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() + ); + + CREATE TABLE IF NOT EXISTS blobs ( + content_hash TEXT PRIMARY KEY, + s3_key TEXT NOT NULL, + size_bytes BIGINT NOT NULL, + content_type TEXT DEFAULT 'application/octet-stream', + created_at TIMESTAMPTZ DEFAULT NOW() + ); + + CREATE TABLE IF NOT EXISTS artifacts ( + id BIGSERIAL PRIMARY KEY, + remote_name TEXT NOT NULL REFERENCES remotes(name) ON DELETE CASCADE, + path TEXT NOT NULL, + content_hash TEXT NOT NULL REFERENCES blobs(content_hash), + upstream_etag TEXT DEFAULT '', + upstream_last_modified TIMESTAMPTZ, + first_seen_at TIMESTAMPTZ DEFAULT NOW(), + last_fetched_at TIMESTAMPTZ DEFAULT NOW(), + last_accessed_at TIMESTAMPTZ DEFAULT NOW(), + fetch_count BIGINT DEFAULT 1, + access_count BIGINT DEFAULT 1, + UNIQUE(remote_name, path) + ); + + CREATE INDEX IF NOT EXISTS idx_artifacts_remote ON artifacts(remote_name); + CREATE INDEX IF NOT EXISTS idx_artifacts_last_accessed ON artifacts(last_accessed_at); + + CREATE TABLE IF NOT EXISTS local_files ( + id BIGSERIAL PRIMARY KEY, + repo_name TEXT NOT NULL, + file_path TEXT NOT NULL, + content_hash TEXT NOT NULL REFERENCES blobs(content_hash), + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(repo_name, file_path) + ); + + CREATE TABLE IF NOT EXISTS access_log ( + id BIGSERIAL PRIMARY KEY, + remote_name TEXT NOT NULL, + path TEXT NOT NULL, + cache_hit BOOLEAN NOT NULL, + size_bytes BIGINT DEFAULT 0, + upstream_ms INTEGER DEFAULT 0, + client_ip TEXT DEFAULT '', + created_at TIMESTAMPTZ DEFAULT NOW() + ); + + CREATE INDEX IF NOT EXISTS idx_access_log_remote_time ON access_log(remote_name, created_at); + `) + return err +} diff --git a/internal/database/remotes.go b/internal/database/remotes.go new file mode 100644 index 0000000..4704c27 --- /dev/null +++ b/internal/database/remotes.go @@ -0,0 +1,99 @@ +package database + +import ( + "context" + + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +const remoteCols = `name, package_type, base_url, description, username, password, + immutable_ttl, mutable_ttl, check_mutable, + patterns, blocklist, mutable_patterns, immutable_patterns, + ban_tags_enabled, ban_tags, + quarantine_enabled, quarantine_days, stale_on_error, + releases_remote, managed_by, created_at, updated_at` + +func scanRemote(scanner interface{ Scan(...any) error }, r *models.Remote) error { + return scanner.Scan( + &r.Name, &r.PackageType, &r.BaseURL, &r.Description, &r.Username, &r.Password, + &r.ImmutableTTL, &r.MutableTTL, &r.CheckMutable, + &r.Patterns, &r.Blocklist, &r.MutablePatterns, &r.ImmutablePatterns, + &r.BanTagsEnabled, &r.BanTags, + &r.QuarantineEnabled, &r.QuarantineDays, &r.StaleOnError, + &r.ReleasesRemote, &r.ManagedBy, &r.CreatedAt, &r.UpdatedAt, + ) +} + +func (db *DB) GetRemote(ctx context.Context, name string) (*models.Remote, error) { + row := db.Pool.QueryRow(ctx, `SELECT `+remoteCols+` FROM remotes WHERE name = $1`, name) + var r models.Remote + if err := scanRemote(row, &r); err != nil { + return nil, err + } + return &r, nil +} + +func (db *DB) ListRemotes(ctx context.Context) ([]models.Remote, error) { + rows, err := db.Pool.Query(ctx, `SELECT `+remoteCols+` FROM remotes ORDER BY name`) + if err != nil { + return nil, err + } + defer rows.Close() + + var remotes []models.Remote + for rows.Next() { + var r models.Remote + if err := scanRemote(rows, &r); err != nil { + return nil, err + } + remotes = append(remotes, r) + } + return remotes, rows.Err() +} + +func (db *DB) CreateRemote(ctx context.Context, r *models.Remote) error { + _, err := db.Pool.Exec(ctx, ` + INSERT INTO remotes ( + name, package_type, base_url, description, username, password, + immutable_ttl, mutable_ttl, check_mutable, + patterns, blocklist, mutable_patterns, immutable_patterns, + ban_tags_enabled, ban_tags, + quarantine_enabled, quarantine_days, stale_on_error, + releases_remote, managed_by + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20) + `, + r.Name, r.PackageType, r.BaseURL, r.Description, r.Username, r.Password, + r.ImmutableTTL, r.MutableTTL, r.CheckMutable, + r.Patterns, r.Blocklist, r.MutablePatterns, r.ImmutablePatterns, + r.BanTagsEnabled, r.BanTags, + r.QuarantineEnabled, r.QuarantineDays, r.StaleOnError, + r.ReleasesRemote, r.ManagedBy, + ) + return err +} + +func (db *DB) UpdateRemote(ctx context.Context, r *models.Remote) error { + _, err := db.Pool.Exec(ctx, ` + UPDATE remotes SET + package_type=$2, base_url=$3, description=$4, username=$5, password=$6, + immutable_ttl=$7, mutable_ttl=$8, check_mutable=$9, + patterns=$10, blocklist=$11, mutable_patterns=$12, immutable_patterns=$13, + ban_tags_enabled=$14, ban_tags=$15, + quarantine_enabled=$16, quarantine_days=$17, stale_on_error=$18, + releases_remote=$19, managed_by=$20, updated_at=NOW() + WHERE name=$1 + `, + r.Name, r.PackageType, r.BaseURL, r.Description, r.Username, r.Password, + r.ImmutableTTL, r.MutableTTL, r.CheckMutable, + r.Patterns, r.Blocklist, r.MutablePatterns, r.ImmutablePatterns, + r.BanTagsEnabled, r.BanTags, + r.QuarantineEnabled, r.QuarantineDays, r.StaleOnError, + r.ReleasesRemote, r.ManagedBy, + ) + return err +} + +func (db *DB) DeleteRemote(ctx context.Context, name string) error { + _, err := db.Pool.Exec(ctx, `DELETE FROM remotes WHERE name = $1`, name) + return err +} diff --git a/internal/database/stats.go b/internal/database/stats.go new file mode 100644 index 0000000..74812b3 --- /dev/null +++ b/internal/database/stats.go @@ -0,0 +1,78 @@ +package database + +import ( + "context" + + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +func (db *DB) GetOverviewStats(ctx context.Context) (*models.OverviewStats, error) { + var stats models.OverviewStats + + err := db.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM remotes`).Scan(&stats.TotalRemotes) + if err != nil { + return nil, err + } + + err = db.Pool.QueryRow(ctx, `SELECT COALESCE(COUNT(*), 0), COALESCE(SUM(b.size_bytes), 0) + FROM artifacts a JOIN blobs b ON a.content_hash = b.content_hash`). + Scan(&stats.TotalObjects, &stats.TotalBytes) + if err != nil { + return nil, err + } + + err = db.Pool.QueryRow(ctx, ` + SELECT COALESCE( + (SELECT COUNT(*) FROM artifacts) - (SELECT COUNT(DISTINCT content_hash) FROM artifacts), + 0 + )`).Scan(&stats.TotalBlobsDeduped) + if err != nil { + return nil, err + } + + return &stats, nil +} + +type RemoteStatRow struct { + Name string `json:"name"` + ObjectCount int64 `json:"object_count"` + TotalBytes int64 `json:"total_bytes"` + Requests30d int64 `json:"requests_30d"` +} + +func (db *DB) GetTopRemotes(ctx context.Context, limit int) ([]RemoteStatRow, error) { + rows, err := db.Pool.Query(ctx, ` + SELECT r.name, + COALESCE(a.cnt, 0) AS object_count, + COALESCE(a.total_bytes, 0) AS total_bytes, + COALESCE(l.req_count, 0) AS requests_30d + FROM remotes r + LEFT JOIN ( + SELECT remote_name, COUNT(*) AS cnt, SUM(b.size_bytes) AS total_bytes + FROM artifacts a JOIN blobs b ON a.content_hash = b.content_hash + GROUP BY remote_name + ) a ON r.name = a.remote_name + LEFT JOIN ( + SELECT remote_name, COUNT(*) AS req_count + FROM access_log + WHERE created_at > NOW() - INTERVAL '30 days' + GROUP BY remote_name + ) l ON r.name = l.remote_name + ORDER BY COALESCE(a.total_bytes, 0) DESC + LIMIT $1 + `, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []RemoteStatRow + for rows.Next() { + var r RemoteStatRow + if err := rows.Scan(&r.Name, &r.ObjectCount, &r.TotalBytes, &r.Requests30d); err != nil { + return nil, err + } + result = append(result, r) + } + return result, rows.Err() +} diff --git a/internal/database/virtuals.go b/internal/database/virtuals.go new file mode 100644 index 0000000..ffe815c --- /dev/null +++ b/internal/database/virtuals.go @@ -0,0 +1,64 @@ +package database + +import ( + "context" + + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +func (db *DB) GetVirtual(ctx context.Context, name string) (*models.Virtual, error) { + row := db.Pool.QueryRow(ctx, ` + SELECT name, package_type, description, members, managed_by, created_at, updated_at + FROM virtuals WHERE name = $1 + `, name) + + var v models.Virtual + err := row.Scan(&v.Name, &v.PackageType, &v.Description, &v.Members, &v.ManagedBy, &v.CreatedAt, &v.UpdatedAt) + if err != nil { + return nil, err + } + return &v, nil +} + +func (db *DB) ListVirtuals(ctx context.Context) ([]models.Virtual, error) { + rows, err := db.Pool.Query(ctx, ` + SELECT name, package_type, description, members, managed_by, created_at, updated_at + FROM virtuals ORDER BY name + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var virtuals []models.Virtual + for rows.Next() { + var v models.Virtual + if err := rows.Scan(&v.Name, &v.PackageType, &v.Description, &v.Members, &v.ManagedBy, &v.CreatedAt, &v.UpdatedAt); err != nil { + return nil, err + } + virtuals = append(virtuals, v) + } + return virtuals, rows.Err() +} + +func (db *DB) CreateVirtual(ctx context.Context, v *models.Virtual) error { + _, err := db.Pool.Exec(ctx, ` + INSERT INTO virtuals (name, package_type, description, members, managed_by) + VALUES ($1, $2, $3, $4, $5) + `, v.Name, v.PackageType, v.Description, v.Members, v.ManagedBy) + return err +} + +func (db *DB) UpdateVirtual(ctx context.Context, v *models.Virtual) error { + _, err := db.Pool.Exec(ctx, ` + UPDATE virtuals SET + package_type=$2, description=$3, members=$4, managed_by=$5, updated_at=NOW() + WHERE name=$1 + `, v.Name, v.PackageType, v.Description, v.Members, v.ManagedBy) + return err +} + +func (db *DB) DeleteVirtual(ctx context.Context, name string) error { + _, err := db.Pool.Exec(ctx, `DELETE FROM virtuals WHERE name = $1`, name) + return err +} diff --git a/internal/gc/gc.go b/internal/gc/gc.go new file mode 100644 index 0000000..d024334 --- /dev/null +++ b/internal/gc/gc.go @@ -0,0 +1,67 @@ +package gc + +import ( + "context" + "log/slog" + "time" + + "git.unkin.net/unkin/artifactapi/internal/database" + "git.unkin.net/unkin/artifactapi/internal/storage" +) + +type Collector struct { + db *database.DB + store *storage.S3 + interval time.Duration +} + +func New(db *database.DB, store *storage.S3, interval time.Duration) *Collector { + return &Collector{db: db, store: store, interval: interval} +} + +func (c *Collector) Run(ctx context.Context) { + slog.Info("gc started", "interval", c.interval) + ticker := time.NewTicker(c.interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + slog.Info("gc stopped") + return + case <-ticker.C: + c.sweep(ctx) + } + } +} + +func (c *Collector) sweep(ctx context.Context) { + start := time.Now() + + orphaned, err := c.db.FindOrphanedBlobs(ctx) + if err != nil { + slog.Error("gc: find orphaned blobs", "error", err) + return + } + + deleted := 0 + for _, blob := range orphaned { + if err := c.store.Delete(ctx, blob.S3Key); err != nil { + slog.Warn("gc: delete s3 object", "key", blob.S3Key, "error", err) + continue + } + if err := c.db.DeleteBlob(ctx, blob.ContentHash); err != nil { + slog.Warn("gc: delete blob row", "hash", blob.ContentHash, "error", err) + continue + } + deleted++ + } + + if deleted > 0 || len(orphaned) > 0 { + slog.Info("gc sweep complete", + "orphaned_found", len(orphaned), + "deleted", deleted, + "duration_ms", time.Since(start).Milliseconds(), + ) + } +} diff --git a/internal/gc/gc_test.go b/internal/gc/gc_test.go new file mode 100644 index 0000000..4338524 --- /dev/null +++ b/internal/gc/gc_test.go @@ -0,0 +1,15 @@ +package gc_test + +import ( + "testing" + "time" + + "git.unkin.net/unkin/artifactapi/internal/gc" +) + +func TestNew(t *testing.T) { + c := gc.New(nil, nil, 1*time.Hour) + if c == nil { + t.Fatal("expected non-nil collector") + } +} diff --git a/internal/provider/alpine/alpine.go b/internal/provider/alpine/alpine.go new file mode 100644 index 0000000..3c5e3e9 --- /dev/null +++ b/internal/provider/alpine/alpine.go @@ -0,0 +1,48 @@ +package alpine + +import ( + "context" + "net/http" + "strings" + + "git.unkin.net/unkin/artifactapi/internal/auth" + "git.unkin.net/unkin/artifactapi/internal/provider" + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +func init() { + provider.Register(&Provider{}) +} + +type Provider struct{} + +func (p *Provider) Type() models.PackageType { return models.PackageAlpine } + +func (p *Provider) Classify(path string) provider.Mutability { + if strings.HasSuffix(path, "APKINDEX.tar.gz") { + return provider.Mutable + } + return provider.Immutable +} + +func (p *Provider) ContentType(path string) string { + if strings.HasSuffix(path, ".apk") { + return "application/vnd.android.package-archive" + } + if strings.HasSuffix(path, ".tar.gz") { + return "application/gzip" + } + return "application/octet-stream" +} + +func (p *Provider) UpstreamURL(remote models.Remote, path string) string { + return strings.TrimRight(remote.BaseURL, "/") + "/" + strings.TrimLeft(path, "/") +} + +func (p *Provider) RewriteResponse(_ []byte, _ models.Remote, _ string) ([]byte, error) { + return nil, nil +} + +func (p *Provider) AuthHeaders(_ context.Context, remote models.Remote) (http.Header, error) { + return auth.BasicHeaders(remote), nil +} diff --git a/internal/provider/docker/docker.go b/internal/provider/docker/docker.go new file mode 100644 index 0000000..36c33ab --- /dev/null +++ b/internal/provider/docker/docker.go @@ -0,0 +1,62 @@ +package docker + +import ( + "context" + "encoding/base64" + "net/http" + "regexp" + "strings" + + "git.unkin.net/unkin/artifactapi/internal/provider" + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +func init() { + provider.Register(&Provider{}) +} + +var ( + tagManifestRe = regexp.MustCompile(`/manifests/[^/]+$`) + digestManifestRe = regexp.MustCompile(`/manifests/sha256:[0-9a-fA-F]+$`) + tagsListRe = regexp.MustCompile(`/tags/list$`) +) + +type Provider struct{} + +func (p *Provider) Type() models.PackageType { return models.PackageDocker } + +func (p *Provider) Classify(path string) provider.Mutability { + if tagsListRe.MatchString(path) { + return provider.Mutable + } + if tagManifestRe.MatchString(path) && !digestManifestRe.MatchString(path) { + return provider.Mutable + } + return provider.Immutable +} + +func (p *Provider) ContentType(path string) string { + if strings.Contains(path, "/blobs/") { + return "application/octet-stream" + } + if strings.Contains(path, "/manifests/") { + return "application/vnd.docker.distribution.manifest.v2+json" + } + return "application/json" +} + +func (p *Provider) UpstreamURL(remote models.Remote, path string) string { + return strings.TrimRight(remote.BaseURL, "/") + "/v2/" + strings.TrimLeft(path, "/") +} + +func (p *Provider) RewriteResponse(_ []byte, _ models.Remote, _ string) ([]byte, error) { + return nil, nil +} + +func (p *Provider) AuthHeaders(_ context.Context, remote models.Remote) (http.Header, error) { + h := http.Header{} + if remote.Username != "" && remote.Password != "" { + h.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(remote.Username+":"+remote.Password))) + } + return h, nil +} diff --git a/internal/provider/docker/docker_test.go b/internal/provider/docker/docker_test.go new file mode 100644 index 0000000..d7c9840 --- /dev/null +++ b/internal/provider/docker/docker_test.go @@ -0,0 +1,54 @@ +package docker_test + +import ( + "testing" + + "git.unkin.net/unkin/artifactapi/internal/provider" + "git.unkin.net/unkin/artifactapi/internal/provider/docker" + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +func TestProvider_Type(t *testing.T) { + p := &docker.Provider{} + if p.Type() != models.PackageDocker { + t.Errorf("expected docker, got %q", p.Type()) + } +} + +func TestProvider_Classify(t *testing.T) { + p := &docker.Provider{} + tests := []struct { + path string + want provider.Mutability + }{ + {"library/nginx/manifests/latest", provider.Mutable}, + {"library/nginx/manifests/v1.25", provider.Mutable}, + {"library/nginx/manifests/sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", provider.Immutable}, + {"library/nginx/tags/list", provider.Mutable}, + {"library/nginx/blobs/sha256:abc123", provider.Immutable}, + } + for _, tt := range tests { + if got := p.Classify(tt.path); got != tt.want { + t.Errorf("Classify(%q) = %v, want %v", tt.path, got, tt.want) + } + } +} + +func TestProvider_UpstreamURL(t *testing.T) { + p := &docker.Provider{} + got := p.UpstreamURL(models.Remote{BaseURL: "https://registry-1.docker.io"}, "library/nginx/manifests/latest") + want := "https://registry-1.docker.io/v2/library/nginx/manifests/latest" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestProvider_ContentType(t *testing.T) { + p := &docker.Provider{} + if p.ContentType("x/blobs/sha256:abc") != "application/octet-stream" { + t.Error("blobs should be octet-stream") + } + if p.ContentType("x/manifests/latest") != "application/vnd.docker.distribution.manifest.v2+json" { + t.Error("manifests should be manifest type") + } +} diff --git a/internal/provider/generic/generic.go b/internal/provider/generic/generic.go new file mode 100644 index 0000000..3374eb0 --- /dev/null +++ b/internal/provider/generic/generic.go @@ -0,0 +1,68 @@ +package generic + +import ( + "context" + "encoding/base64" + "net/http" + "path" + "strings" + + "git.unkin.net/unkin/artifactapi/internal/provider" + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +func init() { + provider.Register(&Provider{}) +} + +type Provider struct{} + +func (p *Provider) Type() models.PackageType { return models.PackageGeneric } + +func (p *Provider) Classify(_ string) provider.Mutability { + return provider.Immutable +} + +var contentTypeMap = map[string]string{ + ".tar.gz": "application/gzip", + ".tgz": "application/gzip", + ".gz": "application/gzip", + ".zip": "application/zip", + ".whl": "application/zip", + ".exe": "application/x-msdownload", + ".rpm": "application/x-rpm", + ".xml": "application/xml", + ".yaml": "text/yaml", + ".yml": "text/yaml", + ".json": "application/json", + ".sig": "application/octet-stream", +} + +func (p *Provider) ContentType(filePath string) string { + lower := strings.ToLower(filePath) + if strings.HasSuffix(lower, ".tar.gz") { + return "application/gzip" + } + ext := path.Ext(lower) + if ct, ok := contentTypeMap[ext]; ok { + return ct + } + return "application/octet-stream" +} + +func (p *Provider) UpstreamURL(remote models.Remote, reqPath string) string { + base := strings.TrimRight(remote.BaseURL, "/") + return base + "/" + strings.TrimLeft(reqPath, "/") +} + +func (p *Provider) RewriteResponse(_ []byte, _ models.Remote, _ string) ([]byte, error) { + return nil, nil +} + +func (p *Provider) AuthHeaders(_ context.Context, remote models.Remote) (http.Header, error) { + h := http.Header{} + if remote.Username != "" { + h.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(remote.Username+":"+remote.Password))) + } + return h, nil +} diff --git a/internal/provider/generic/generic_test.go b/internal/provider/generic/generic_test.go new file mode 100644 index 0000000..f3c683c --- /dev/null +++ b/internal/provider/generic/generic_test.go @@ -0,0 +1,69 @@ +package generic_test + +import ( + "context" + "testing" + + "git.unkin.net/unkin/artifactapi/internal/provider" + "git.unkin.net/unkin/artifactapi/internal/provider/generic" + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +func TestProvider_Type(t *testing.T) { + p := &generic.Provider{} + if p.Type() != models.PackageGeneric { + t.Errorf("expected generic, got %q", p.Type()) + } +} + +func TestProvider_Classify_AllImmutable(t *testing.T) { + p := &generic.Provider{} + paths := []string{"file.tar.gz", "path/to/binary", "index.html", "data.json"} + for _, path := range paths { + if p.Classify(path) != provider.Immutable { + t.Errorf("generic should classify %q as immutable", path) + } + } +} + +func TestProvider_ContentType(t *testing.T) { + p := &generic.Provider{} + tests := []struct{ path, want string }{ + {"file.tar.gz", "application/gzip"}, + {"file.tgz", "application/gzip"}, + {"file.zip", "application/zip"}, + {"file.rpm", "application/x-rpm"}, + {"file.json", "application/json"}, + {"file.unknown", "application/octet-stream"}, + } + for _, tt := range tests { + if got := p.ContentType(tt.path); got != tt.want { + t.Errorf("ContentType(%q) = %q, want %q", tt.path, got, tt.want) + } + } +} + +func TestProvider_UpstreamURL(t *testing.T) { + p := &generic.Provider{} + got := p.UpstreamURL(models.Remote{BaseURL: "https://example.com/repo"}, "path/to/file.tar.gz") + want := "https://example.com/repo/path/to/file.tar.gz" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestProvider_AuthHeaders_BasicAuth(t *testing.T) { + p := &generic.Provider{} + h, _ := p.AuthHeaders(context.Background(), models.Remote{Username: "user", Password: "pass"}) + if h.Get("Authorization") != "Basic dXNlcjpwYXNz" { + t.Errorf("unexpected auth header: %q", h.Get("Authorization")) + } +} + +func TestProvider_AuthHeaders_NoAuth(t *testing.T) { + p := &generic.Provider{} + h, _ := p.AuthHeaders(context.Background(), models.Remote{}) + if h.Get("Authorization") != "" { + t.Error("expected no auth header") + } +} diff --git a/internal/provider/goproxy/goproxy.go b/internal/provider/goproxy/goproxy.go new file mode 100644 index 0000000..48e8e71 --- /dev/null +++ b/internal/provider/goproxy/goproxy.go @@ -0,0 +1,54 @@ +package goproxy + +import ( + "context" + "net/http" + "strings" + + "git.unkin.net/unkin/artifactapi/internal/auth" + "git.unkin.net/unkin/artifactapi/internal/provider" + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +func init() { + provider.Register(&Provider{}) +} + +type Provider struct{} + +func (p *Provider) Type() models.PackageType { return models.PackageGoProxy } + +func (p *Provider) Classify(path string) provider.Mutability { + if strings.HasSuffix(path, "/@v/list") || strings.HasSuffix(path, "/@latest") { + return provider.Mutable + } + return provider.Immutable +} + +func (p *Provider) ContentType(path string) string { + if strings.HasSuffix(path, ".zip") { + return "application/zip" + } + if strings.HasSuffix(path, ".mod") { + return "text/plain" + } + if strings.HasSuffix(path, ".info") { + return "application/json" + } + if strings.HasSuffix(path, "/list") { + return "text/plain" + } + return "application/octet-stream" +} + +func (p *Provider) UpstreamURL(remote models.Remote, path string) string { + return strings.TrimRight(remote.BaseURL, "/") + "/" + strings.TrimLeft(path, "/") +} + +func (p *Provider) RewriteResponse(_ []byte, _ models.Remote, _ string) ([]byte, error) { + return nil, nil +} + +func (p *Provider) AuthHeaders(_ context.Context, remote models.Remote) (http.Header, error) { + return auth.BasicHeaders(remote), nil +} diff --git a/internal/provider/goproxy/goproxy_test.go b/internal/provider/goproxy/goproxy_test.go new file mode 100644 index 0000000..2ca5f90 --- /dev/null +++ b/internal/provider/goproxy/goproxy_test.go @@ -0,0 +1,50 @@ +package goproxy_test + +import ( + "testing" + + "git.unkin.net/unkin/artifactapi/internal/provider" + "git.unkin.net/unkin/artifactapi/internal/provider/goproxy" + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +func TestProvider_Type(t *testing.T) { + p := &goproxy.Provider{} + if p.Type() != models.PackageGoProxy { + t.Errorf("expected goproxy, got %q", p.Type()) + } +} + +func TestProvider_Classify(t *testing.T) { + p := &goproxy.Provider{} + tests := []struct { + path string + want provider.Mutability + }{ + {"golang.org/x/net/@v/list", provider.Mutable}, + {"golang.org/x/net/@latest", provider.Mutable}, + {"golang.org/x/net/@v/v0.1.0.info", provider.Immutable}, + {"golang.org/x/net/@v/v0.1.0.mod", provider.Immutable}, + {"golang.org/x/net/@v/v0.1.0.zip", provider.Immutable}, + } + for _, tt := range tests { + if got := p.Classify(tt.path); got != tt.want { + t.Errorf("Classify(%q) = %v, want %v", tt.path, got, tt.want) + } + } +} + +func TestProvider_ContentType(t *testing.T) { + p := &goproxy.Provider{} + tests := []struct{ path, want string }{ + {"m/@v/v1.0.0.zip", "application/zip"}, + {"m/@v/v1.0.0.mod", "text/plain"}, + {"m/@v/v1.0.0.info", "application/json"}, + {"m/@v/list", "text/plain"}, + } + for _, tt := range tests { + if got := p.ContentType(tt.path); got != tt.want { + t.Errorf("ContentType(%q) = %q, want %q", tt.path, got, tt.want) + } + } +} diff --git a/internal/provider/helm/helm.go b/internal/provider/helm/helm.go new file mode 100644 index 0000000..d37765e --- /dev/null +++ b/internal/provider/helm/helm.go @@ -0,0 +1,58 @@ +package helm + +import ( + "context" + "net/http" + "strings" + + "git.unkin.net/unkin/artifactapi/internal/auth" + "git.unkin.net/unkin/artifactapi/internal/provider" + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +func init() { + provider.Register(&Provider{}) +} + +type Provider struct{} + +func (p *Provider) Type() models.PackageType { return models.PackageHelm } + +func (p *Provider) Classify(path string) provider.Mutability { + if strings.HasSuffix(path, "index.yaml") || strings.HasSuffix(path, "index.yml") { + return provider.Mutable + } + return provider.Immutable +} + +func (p *Provider) ContentType(path string) string { + if strings.HasSuffix(path, ".tgz") || strings.HasSuffix(path, ".tar.gz") { + return "application/gzip" + } + if strings.HasSuffix(path, ".yaml") || strings.HasSuffix(path, ".yml") { + return "text/yaml" + } + return "application/octet-stream" +} + +func (p *Provider) UpstreamURL(remote models.Remote, path string) string { + return strings.TrimRight(remote.BaseURL, "/") + "/" + strings.TrimLeft(path, "/") +} + +func (p *Provider) RewriteResponse(body []byte, remote models.Remote, proxyBaseURL string) ([]byte, error) { + if proxyBaseURL == "" { + return nil, nil + } + content := string(body) + baseURL := strings.TrimRight(remote.BaseURL, "/") + proxyURL := strings.TrimRight(proxyBaseURL, "/") + "/api/v1/remote/" + remote.Name + rewritten := strings.ReplaceAll(content, baseURL, proxyURL) + if rewritten == content { + return nil, nil + } + return []byte(rewritten), nil +} + +func (p *Provider) AuthHeaders(_ context.Context, remote models.Remote) (http.Header, error) { + return auth.BasicHeaders(remote), nil +} diff --git a/internal/provider/helm/helm_test.go b/internal/provider/helm/helm_test.go new file mode 100644 index 0000000..2f29783 --- /dev/null +++ b/internal/provider/helm/helm_test.go @@ -0,0 +1,51 @@ +package helm_test + +import ( + "strings" + "testing" + + "git.unkin.net/unkin/artifactapi/internal/provider" + "git.unkin.net/unkin/artifactapi/internal/provider/helm" + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +func TestProvider_Type(t *testing.T) { + p := &helm.Provider{} + if p.Type() != models.PackageHelm { + t.Errorf("expected helm, got %q", p.Type()) + } +} + +func TestProvider_Classify(t *testing.T) { + p := &helm.Provider{} + tests := []struct { + path string + want provider.Mutability + }{ + {"index.yaml", provider.Mutable}, + {"index.yml", provider.Mutable}, + {"chart-1.0.tgz", provider.Immutable}, + {"charts/nginx-1.0.tgz", provider.Immutable}, + } + for _, tt := range tests { + if got := p.Classify(tt.path); got != tt.want { + t.Errorf("Classify(%q) = %v, want %v", tt.path, got, tt.want) + } + } +} + +func TestProvider_RewriteResponse(t *testing.T) { + p := &helm.Provider{} + body := []byte("urls:\n- https://charts.example.com/chart-1.0.tgz") + remote := models.Remote{Name: "helm-test", BaseURL: "https://charts.example.com"} + rewritten, err := p.RewriteResponse(body, remote, "https://proxy.example.com") + if err != nil { + t.Fatal(err) + } + if rewritten == nil { + t.Fatal("expected rewrite") + } + if !strings.Contains(string(rewritten), "proxy.example.com/api/v1/remote/helm-test") { + t.Errorf("expected proxy URL in body: %s", rewritten) + } +} diff --git a/internal/provider/npm/npm.go b/internal/provider/npm/npm.go new file mode 100644 index 0000000..38cf32d --- /dev/null +++ b/internal/provider/npm/npm.go @@ -0,0 +1,56 @@ +package npm + +import ( + "context" + "encoding/json" + "net/http" + "strings" + + "git.unkin.net/unkin/artifactapi/internal/auth" + "git.unkin.net/unkin/artifactapi/internal/provider" + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +func init() { + provider.Register(&Provider{}) +} + +type Provider struct{} + +func (p *Provider) Type() models.PackageType { return models.PackageNPM } + +func (p *Provider) Classify(path string) provider.Mutability { + if strings.HasSuffix(path, ".tgz") { + return provider.Immutable + } + return provider.Mutable +} + +func (p *Provider) ContentType(path string) string { + if strings.HasSuffix(path, ".tgz") { + return "application/gzip" + } + return "application/json" +} + +func (p *Provider) UpstreamURL(remote models.Remote, path string) string { + return strings.TrimRight(remote.BaseURL, "/") + "/" + strings.TrimLeft(path, "/") +} + +func (p *Provider) RewriteResponse(body []byte, remote models.Remote, proxyBaseURL string) ([]byte, error) { + if proxyBaseURL == "" || !json.Valid(body) { + return nil, nil + } + content := string(body) + baseURL := strings.TrimRight(remote.BaseURL, "/") + proxyURL := strings.TrimRight(proxyBaseURL, "/") + "/api/v1/remote/" + remote.Name + rewritten := strings.ReplaceAll(content, baseURL, proxyURL) + if rewritten == content { + return nil, nil + } + return []byte(rewritten), nil +} + +func (p *Provider) AuthHeaders(_ context.Context, remote models.Remote) (http.Header, error) { + return auth.BasicHeaders(remote), nil +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go new file mode 100644 index 0000000..10fce92 --- /dev/null +++ b/internal/provider/provider.go @@ -0,0 +1,52 @@ +package provider + +import ( + "context" + "fmt" + "net/http" + + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +type Mutability int + +const ( + Immutable Mutability = iota + Mutable +) + +type Provider interface { + Type() models.PackageType + Classify(path string) Mutability + ContentType(path string) string + UpstreamURL(remote models.Remote, path string) string + RewriteResponse(body []byte, remote models.Remote, proxyBaseURL string) ([]byte, error) + AuthHeaders(ctx context.Context, remote models.Remote) (http.Header, error) +} + +type IndexMerger interface { + MergeIndexes(members []MemberIndex, proxyBaseURL string) ([]byte, error) +} + +type MemberIndex struct { + RemoteName string + Body []byte +} + +var registry = map[models.PackageType]Provider{} + +func Register(p Provider) { + registry[p.Type()] = p +} + +func Get(t models.PackageType) (Provider, error) { + p, ok := registry[t] + if !ok { + return nil, fmt.Errorf("no provider registered for package type %q", t) + } + return p, nil +} + +func All() map[models.PackageType]Provider { + return registry +} diff --git a/internal/provider/puppet/puppet.go b/internal/provider/puppet/puppet.go new file mode 100644 index 0000000..142a431 --- /dev/null +++ b/internal/provider/puppet/puppet.go @@ -0,0 +1,56 @@ +package puppet + +import ( + "context" + "net/http" + "strings" + + "git.unkin.net/unkin/artifactapi/internal/auth" + "git.unkin.net/unkin/artifactapi/internal/provider" + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +func init() { + provider.Register(&Provider{}) +} + +type Provider struct{} + +func (p *Provider) Type() models.PackageType { return models.PackagePuppet } + +func (p *Provider) Classify(path string) provider.Mutability { + if strings.HasPrefix(path, "v3/modules/") || strings.HasPrefix(path, "v3/releases") { + return provider.Mutable + } + return provider.Immutable +} + +func (p *Provider) ContentType(path string) string { + if strings.HasSuffix(path, ".tar.gz") { + return "application/gzip" + } + if strings.HasPrefix(path, "v3/") { + return "application/json" + } + return "application/octet-stream" +} + +func (p *Provider) UpstreamURL(remote models.Remote, path string) string { + return strings.TrimRight(remote.BaseURL, "/") + "/" + strings.TrimLeft(path, "/") +} + +func (p *Provider) RewriteResponse(body []byte, remote models.Remote, proxyBaseURL string) ([]byte, error) { + if proxyBaseURL == "" { + return nil, nil + } + content := string(body) + proxyURL := strings.TrimRight(proxyBaseURL, "/") + "/api/v1/remote/" + remote.Name + content = strings.ReplaceAll(content, `"/v3/files/`, `"`+proxyURL+`/v3/files/`) + baseURL := strings.TrimRight(remote.BaseURL, "/") + content = strings.ReplaceAll(content, baseURL, proxyURL) + return []byte(content), nil +} + +func (p *Provider) AuthHeaders(_ context.Context, remote models.Remote) (http.Header, error) { + return auth.BasicHeaders(remote), nil +} diff --git a/internal/provider/pypi/pypi.go b/internal/provider/pypi/pypi.go new file mode 100644 index 0000000..8956247 --- /dev/null +++ b/internal/provider/pypi/pypi.go @@ -0,0 +1,62 @@ +package pypi + +import ( + "context" + "net/http" + "strings" + + "git.unkin.net/unkin/artifactapi/internal/auth" + "git.unkin.net/unkin/artifactapi/internal/provider" + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +func init() { + provider.Register(&Provider{}) +} + +type Provider struct{} + +func (p *Provider) Type() models.PackageType { return models.PackagePyPI } + +func (p *Provider) Classify(path string) provider.Mutability { + if strings.Contains(path, "simple/") { + return provider.Mutable + } + return provider.Immutable +} + +func (p *Provider) ContentType(path string) string { + lower := strings.ToLower(path) + if strings.HasSuffix(lower, ".whl") || strings.HasSuffix(lower, ".zip") { + return "application/zip" + } + if strings.HasSuffix(lower, ".tar.gz") { + return "application/gzip" + } + if strings.Contains(path, "simple/") { + return "text/html" + } + return "application/octet-stream" +} + +func (p *Provider) UpstreamURL(remote models.Remote, path string) string { + if strings.HasPrefix(path, "simple/") { + return "https://pypi.org/" + path + } + return strings.TrimRight(remote.BaseURL, "/") + "/" + strings.TrimLeft(path, "/") +} + +func (p *Provider) RewriteResponse(body []byte, remote models.Remote, proxyBaseURL string) ([]byte, error) { + if proxyBaseURL == "" { + return nil, nil + } + content := string(body) + proxyURL := strings.TrimRight(proxyBaseURL, "/") + "/api/v1/remote/" + remote.Name + "/" + content = strings.ReplaceAll(content, "https://files.pythonhosted.org/", proxyURL) + content = strings.ReplaceAll(content, "../../", proxyURL) + return []byte(content), nil +} + +func (p *Provider) AuthHeaders(_ context.Context, remote models.Remote) (http.Header, error) { + return auth.BasicHeaders(remote), nil +} diff --git a/internal/provider/rpm/rpm.go b/internal/provider/rpm/rpm.go new file mode 100644 index 0000000..511460c --- /dev/null +++ b/internal/provider/rpm/rpm.go @@ -0,0 +1,57 @@ +package rpm + +import ( + "context" + "net/http" + "regexp" + "strings" + + "git.unkin.net/unkin/artifactapi/internal/auth" + "git.unkin.net/unkin/artifactapi/internal/provider" + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +func init() { + provider.Register(&Provider{}) +} + +var mutableRe = []*regexp.Regexp{ + regexp.MustCompile(`repomd\.xml$`), + regexp.MustCompile(`repodata/`), + regexp.MustCompile(`Packages\.gz$`), +} + +type Provider struct{} + +func (p *Provider) Type() models.PackageType { return models.PackageRPM } + +func (p *Provider) Classify(path string) provider.Mutability { + for _, re := range mutableRe { + if re.MatchString(path) { + return provider.Mutable + } + } + return provider.Immutable +} + +func (p *Provider) ContentType(path string) string { + if strings.HasSuffix(path, ".rpm") { + return "application/x-rpm" + } + if strings.HasSuffix(path, ".xml") || strings.HasSuffix(path, ".xml.gz") || strings.HasSuffix(path, ".xml.xz") { + return "application/xml" + } + return "application/octet-stream" +} + +func (p *Provider) UpstreamURL(remote models.Remote, path string) string { + return strings.TrimRight(remote.BaseURL, "/") + "/" + strings.TrimLeft(path, "/") +} + +func (p *Provider) RewriteResponse(_ []byte, _ models.Remote, _ string) ([]byte, error) { + return nil, nil +} + +func (p *Provider) AuthHeaders(_ context.Context, remote models.Remote) (http.Header, error) { + return auth.BasicHeaders(remote), nil +} diff --git a/internal/provider/rpm/rpm_test.go b/internal/provider/rpm/rpm_test.go new file mode 100644 index 0000000..806f832 --- /dev/null +++ b/internal/provider/rpm/rpm_test.go @@ -0,0 +1,35 @@ +package rpm_test + +import ( + "testing" + + "git.unkin.net/unkin/artifactapi/internal/provider" + "git.unkin.net/unkin/artifactapi/internal/provider/rpm" + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +func TestProvider_Type(t *testing.T) { + p := &rpm.Provider{} + if p.Type() != models.PackageRPM { + t.Errorf("expected rpm, got %q", p.Type()) + } +} + +func TestProvider_Classify(t *testing.T) { + p := &rpm.Provider{} + tests := []struct { + path string + want provider.Mutability + }{ + {"repomd.xml", provider.Mutable}, + {"repodata/primary.xml.gz", provider.Mutable}, + {"Packages.gz", provider.Mutable}, + {"package-1.0.rpm", provider.Immutable}, + {"RPM-GPG-KEY-almalinux", provider.Immutable}, + } + for _, tt := range tests { + if got := p.Classify(tt.path); got != tt.want { + t.Errorf("Classify(%q) = %v, want %v", tt.path, got, tt.want) + } + } +} diff --git a/internal/provider/terraform/terraform.go b/internal/provider/terraform/terraform.go new file mode 100644 index 0000000..9e0009f --- /dev/null +++ b/internal/provider/terraform/terraform.go @@ -0,0 +1,88 @@ +package terraform + +import ( + "context" + "encoding/json" + "net/http" + "net/url" + "regexp" + "strings" + + "git.unkin.net/unkin/artifactapi/internal/auth" + "git.unkin.net/unkin/artifactapi/internal/provider" + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +func init() { + provider.Register(&Provider{}) +} + +var versionsRe = regexp.MustCompile(`[^/]+/[^/]+/versions$`) + +type Provider struct{} + +func (p *Provider) Type() models.PackageType { return models.PackageTerraform } + +func (p *Provider) Classify(path string) provider.Mutability { + if versionsRe.MatchString(path) { + return provider.Mutable + } + return provider.Immutable +} + +func (p *Provider) ContentType(path string) string { + lower := strings.ToLower(path) + if strings.HasSuffix(lower, ".zip") { + return "application/zip" + } + if strings.HasSuffix(lower, ".sig") { + return "application/octet-stream" + } + return "application/json" +} + +func (p *Provider) UpstreamURL(remote models.Remote, path string) string { + return strings.TrimRight(remote.BaseURL, "/") + "/v1/providers/" + strings.TrimLeft(path, "/") +} + +func (p *Provider) RewriteResponse(body []byte, remote models.Remote, proxyBaseURL string) ([]byte, error) { + if remote.ReleasesRemote == "" { + return nil, nil + } + if !json.Valid(body) { + return nil, nil + } + + var data map[string]any + if err := json.Unmarshal(body, &data); err != nil { + return nil, nil + } + + changed := false + for _, field := range []string{"download_url", "shasums_url", "shasums_signature_url"} { + if val, ok := data[field].(string); ok && val != "" { + rewritten := rewriteDownloadURL(val, remote.ReleasesRemote, proxyBaseURL) + if rewritten != val { + data[field] = rewritten + changed = true + } + } + } + + if !changed { + return nil, nil + } + return json.Marshal(data) +} + +func rewriteDownloadURL(originalURL, releasesRemote, proxyBaseURL string) string { + parsed, err := url.Parse(originalURL) + if err != nil || proxyBaseURL == "" { + return originalURL + } + return strings.TrimRight(proxyBaseURL, "/") + "/api/v1/remote/" + releasesRemote + parsed.Path +} + +func (p *Provider) AuthHeaders(_ context.Context, remote models.Remote) (http.Header, error) { + return auth.BasicHeaders(remote), nil +} diff --git a/internal/provider/terraform/terraform_test.go b/internal/provider/terraform/terraform_test.go new file mode 100644 index 0000000..8b5132c --- /dev/null +++ b/internal/provider/terraform/terraform_test.go @@ -0,0 +1,55 @@ +package terraform_test + +import ( + "encoding/json" + "strings" + "testing" + + "git.unkin.net/unkin/artifactapi/internal/provider" + "git.unkin.net/unkin/artifactapi/internal/provider/terraform" + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +func TestProvider_Type(t *testing.T) { + p := &terraform.Provider{} + if p.Type() != models.PackageTerraform { + t.Errorf("expected terraform, got %q", p.Type()) + } +} + +func TestProvider_Classify(t *testing.T) { + p := &terraform.Provider{} + tests := []struct { + path string + want provider.Mutability + }{ + {"hashicorp/vault/versions", provider.Mutable}, + {"hashicorp/vault/0.28.0/download/linux/amd64", provider.Immutable}, + } + for _, tt := range tests { + if got := p.Classify(tt.path); got != tt.want { + t.Errorf("Classify(%q) = %v, want %v", tt.path, got, tt.want) + } + } +} + +func TestProvider_RewriteResponse_DownloadInfo(t *testing.T) { + p := &terraform.Provider{} + remote := models.Remote{Name: "tf", ReleasesRemote: "hashicorp-releases"} + body, _ := json.Marshal(map[string]any{ + "download_url": "https://releases.hashicorp.com/terraform-provider-vault/0.28.0/file.zip", + "shasums_url": "https://releases.hashicorp.com/terraform-provider-vault/0.28.0/SHA256SUMS", + }) + rewritten, err := p.RewriteResponse(body, remote, "https://proxy") + if err != nil { + t.Fatal(err) + } + if rewritten == nil { + t.Fatal("expected rewrite") + } + var result map[string]any + json.Unmarshal(rewritten, &result) + if !strings.Contains(result["download_url"].(string), "proxy/api/v1/remote/hashicorp-releases") { + t.Errorf("download_url not rewritten: %s", result["download_url"]) + } +} diff --git a/internal/proxy/circuit.go b/internal/proxy/circuit.go new file mode 100644 index 0000000..d7b4e6d --- /dev/null +++ b/internal/proxy/circuit.go @@ -0,0 +1,60 @@ +package proxy + +import ( + "context" + "time" + + "git.unkin.net/unkin/artifactapi/internal/cache" + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +const ( + defaultCircuitThreshold = 5 + defaultCircuitCooldown = 60 * time.Second +) + +type CircuitBreaker struct { + cache *cache.Redis + threshold int64 + cooldown time.Duration +} + +func NewCircuitBreaker(c *cache.Redis) *CircuitBreaker { + return &CircuitBreaker{ + cache: c, + threshold: defaultCircuitThreshold, + cooldown: defaultCircuitCooldown, + } +} + +func (cb *CircuitBreaker) IsOpen(ctx context.Context, remote string) bool { + failures, err := cb.cache.GetCircuitFailures(ctx, remote) + if err != nil { + return false + } + return failures >= cb.threshold +} + +func (cb *CircuitBreaker) RecordFailure(ctx context.Context, remote string) { + cb.cache.IncrCircuitFailure(ctx, remote, cb.cooldown) +} + +func (cb *CircuitBreaker) RecordSuccess(ctx context.Context, remote string) { + cb.cache.ResetCircuit(ctx, remote) +} + +func (cb *CircuitBreaker) Health(ctx context.Context, remote string) models.RemoteHealth { + failures, err := cb.cache.GetCircuitFailures(ctx, remote) + if err != nil { + return models.RemoteHealth{Status: "unknown"} + } + + switch { + case failures == 0: + return models.RemoteHealth{Status: "healthy", ConsecutiveFailures: int(failures)} + case failures < cb.threshold: + return models.RemoteHealth{Status: "degraded", ConsecutiveFailures: int(failures)} + default: + return models.RemoteHealth{Status: "down", ConsecutiveFailures: int(failures)} + } +} diff --git a/internal/proxy/circuit_test.go b/internal/proxy/circuit_test.go new file mode 100644 index 0000000..a698b82 --- /dev/null +++ b/internal/proxy/circuit_test.go @@ -0,0 +1,14 @@ +package proxy_test + +import ( + "testing" + + "git.unkin.net/unkin/artifactapi/internal/proxy" +) + +func TestCircuitBreaker_New(t *testing.T) { + cb := proxy.NewCircuitBreaker(nil) + if cb == nil { + t.Fatal("expected non-nil circuit breaker") + } +} diff --git a/internal/proxy/classifier.go b/internal/proxy/classifier.go new file mode 100644 index 0000000..b8bc686 --- /dev/null +++ b/internal/proxy/classifier.go @@ -0,0 +1,80 @@ +package proxy + +import ( + "regexp" + + "git.unkin.net/unkin/artifactapi/internal/provider" + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +type Classification int + +const ( + ClassImmutable Classification = iota + ClassMutable + ClassDenied +) + +func (c Classification) String() string { + switch c { + case ClassImmutable: + return "immutable" + case ClassMutable: + return "mutable" + case ClassDenied: + return "denied" + default: + return "unknown" + } +} + +type Classifier struct { + provider provider.Provider +} + +func NewClassifier(p provider.Provider) *Classifier { + return &Classifier{provider: p} +} + +func (c *Classifier) Classify(remote models.Remote, path string) Classification { + if matchesAny(path, compilePatterns(remote.Blocklist)) { + return ClassDenied + } + + if len(remote.Patterns) > 0 && !matchesAny(path, compilePatterns(remote.Patterns)) { + return ClassDenied + } + + if matchesAny(path, compilePatterns(remote.ImmutablePatterns)) { + return ClassImmutable + } + + if matchesAny(path, compilePatterns(remote.MutablePatterns)) { + return ClassMutable + } + + if c.provider.Classify(path) == provider.Mutable { + return ClassMutable + } + + return ClassImmutable +} + +func compilePatterns(patterns []string) []*regexp.Regexp { + compiled := make([]*regexp.Regexp, 0, len(patterns)) + for _, p := range patterns { + if re, err := regexp.Compile(p); err == nil { + compiled = append(compiled, re) + } + } + return compiled +} + +func matchesAny(path string, patterns []*regexp.Regexp) bool { + for _, re := range patterns { + if re.MatchString(path) { + return true + } + } + return false +} diff --git a/internal/proxy/classifier_test.go b/internal/proxy/classifier_test.go new file mode 100644 index 0000000..5933926 --- /dev/null +++ b/internal/proxy/classifier_test.go @@ -0,0 +1,129 @@ +package proxy_test + +import ( + "testing" + + "git.unkin.net/unkin/artifactapi/internal/provider/docker" + "git.unkin.net/unkin/artifactapi/internal/provider/generic" + "git.unkin.net/unkin/artifactapi/internal/provider/helm" + "git.unkin.net/unkin/artifactapi/internal/provider/rpm" + "git.unkin.net/unkin/artifactapi/internal/proxy" + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +func TestClassifier_EmptyPatternsAllowsAll(t *testing.T) { + c := proxy.NewClassifier(&generic.Provider{}) + remote := models.Remote{Name: "test"} + if c.Classify(remote, "any/path") == proxy.ClassDenied { + t.Error("empty patterns should allow all paths") + } +} + +func TestClassifier_PatternsActAsAllowlist(t *testing.T) { + c := proxy.NewClassifier(&generic.Provider{}) + remote := models.Remote{ + Name: "test", + Patterns: []string{`^releases/`}, + } + if c.Classify(remote, "releases/v1.0/app.tar.gz") == proxy.ClassDenied { + t.Error("path matching patterns should be allowed") + } + if c.Classify(remote, "uploads/other.tar.gz") != proxy.ClassDenied { + t.Error("path not matching patterns should be denied") + } +} + +func TestClassifier_BlocklistDenies(t *testing.T) { + c := proxy.NewClassifier(&generic.Provider{}) + remote := models.Remote{ + Name: "test", + Blocklist: []string{`\.exe$`}, + } + if c.Classify(remote, "malware.exe") != proxy.ClassDenied { + t.Error("blocklist match should deny") + } + if c.Classify(remote, "legit.tar.gz") == proxy.ClassDenied { + t.Error("non-blocked path should be allowed") + } +} + +func TestClassifier_BlocklistBeforePatterns(t *testing.T) { + c := proxy.NewClassifier(&generic.Provider{}) + remote := models.Remote{ + Name: "test", + Patterns: []string{`^releases/`}, + Blocklist: []string{`releases/v0\.1/`}, + } + if c.Classify(remote, "releases/v0.1/app.tar.gz") != proxy.ClassDenied { + t.Error("blocklist should take priority") + } +} + +func TestClassifier_GenericAllImmutable(t *testing.T) { + c := proxy.NewClassifier(&generic.Provider{}) + remote := models.Remote{Name: "test"} + if c.Classify(remote, "any/file.tar.gz") != proxy.ClassImmutable { + t.Error("generic provider should classify everything as immutable") + } +} + +func TestClassifier_GenericMutableOverride(t *testing.T) { + c := proxy.NewClassifier(&generic.Provider{}) + remote := models.Remote{ + Name: "test", + MutablePatterns: []string{`/archive/refs/heads/`}, + } + if c.Classify(remote, "repo/archive/refs/heads/main.tar.gz") != proxy.ClassMutable { + t.Error("mutable_patterns should override provider default") + } + if c.Classify(remote, "repo/releases/v1.0.tar.gz") != proxy.ClassImmutable { + t.Error("non-mutable path should stay immutable") + } +} + +func TestClassifier_ImmutableOverride(t *testing.T) { + c := proxy.NewClassifier(&helm.Provider{}) + remote := models.Remote{ + Name: "test", + ImmutablePatterns: []string{`special-index\.yaml$`}, + } + if c.Classify(remote, "special-index.yaml") != proxy.ClassImmutable { + t.Error("immutable_patterns should force immutable even for normally mutable paths") + } +} + +func TestClassifier_HelmAutoClassifies(t *testing.T) { + c := proxy.NewClassifier(&helm.Provider{}) + remote := models.Remote{Name: "test"} + if c.Classify(remote, "index.yaml") != proxy.ClassMutable { + t.Error("helm should auto-classify index.yaml as mutable") + } + if c.Classify(remote, "chart-1.0.tgz") != proxy.ClassImmutable { + t.Error("helm should auto-classify .tgz as immutable") + } +} + +func TestClassifier_DockerAutoClassifies(t *testing.T) { + c := proxy.NewClassifier(&docker.Provider{}) + remote := models.Remote{Name: "test"} + if c.Classify(remote, "library/nginx/manifests/latest") != proxy.ClassMutable { + t.Error("docker should classify tag manifest as mutable") + } + if c.Classify(remote, "library/nginx/manifests/sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890") != proxy.ClassImmutable { + t.Error("docker should classify digest manifest as immutable") + } + if c.Classify(remote, "library/nginx/blobs/sha256:abc") != proxy.ClassImmutable { + t.Error("docker should classify blobs as immutable") + } +} + +func TestClassifier_RPMAutoClassifies(t *testing.T) { + c := proxy.NewClassifier(&rpm.Provider{}) + remote := models.Remote{Name: "test"} + if c.Classify(remote, "repodata/primary.xml.gz") != proxy.ClassMutable { + t.Error("rpm should classify repodata as mutable") + } + if c.Classify(remote, "packages/foo-1.0.rpm") != proxy.ClassImmutable { + t.Error("rpm should classify .rpm as immutable") + } +} diff --git a/internal/proxy/engine.go b/internal/proxy/engine.go new file mode 100644 index 0000000..ddb75a1 --- /dev/null +++ b/internal/proxy/engine.go @@ -0,0 +1,341 @@ +package proxy + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "log/slog" + "net/http" + "time" + + "git.unkin.net/unkin/artifactapi/internal/cache" + "git.unkin.net/unkin/artifactapi/internal/database" + "git.unkin.net/unkin/artifactapi/internal/provider" + "git.unkin.net/unkin/artifactapi/internal/storage" + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +const fetchLockTTL = 30 * time.Second + +type Engine struct { + db *database.DB + cache *cache.Redis + store *storage.S3 + cas *storage.CAS +} + +func NewEngine(db *database.DB, c *cache.Redis, s *storage.S3) *Engine { + return &Engine{ + db: db, + cache: c, + store: s, + cas: storage.NewCAS(s), + } +} + +type FetchResult struct { + Reader io.ReadCloser + ContentType string + Size int64 + Source string // "cache" or "remote" +} + +func (e *Engine) Fetch(ctx context.Context, remote models.Remote, path string, prov provider.Provider) (*FetchResult, error) { + classifier := NewClassifier(prov) + class := classifier.Classify(remote, path) + + if class == ClassDenied { + return nil, &ProxyError{Status: http.StatusForbidden, Message: "access denied"} + } + + ttl := e.ttlFor(remote, class) + + fresh, err := e.cache.CheckTTL(ctx, remote.Name, path) + if err != nil { + slog.Warn("redis check failed, treating as miss", "error", err) + } + + if fresh { + result, err := e.serveFromStore(ctx, remote, path) + if err == nil { + result.Source = "cache" + go e.logAccess(remote.Name, path, true, result.Size, 0) + return result, nil + } + slog.Warn("cache hit but S3 miss, re-fetching", "remote", remote.Name, "path", path) + } + + locked, err := e.cache.AcquireLock(ctx, remote.Name, path, fetchLockTTL) + if err != nil { + slog.Warn("lock acquire failed", "error", err) + } + + if !locked { + time.Sleep(500 * time.Millisecond) + result, err := e.serveFromStore(ctx, remote, path) + if err == nil { + result.Source = "cache" + go e.logAccess(remote.Name, path, true, result.Size, 0) + return result, nil + } + } + + if locked { + defer e.cache.ReleaseLock(ctx, remote.Name, path) + } + + if class == ClassMutable && remote.CheckMutable { + etag, _ := e.cache.GetETag(ctx, remote.Name, path) + if etag != "" { + notModified, err := e.checkUpstream(ctx, remote, path, etag, prov) + if err == nil && notModified { + _ = e.cache.SetTTL(ctx, remote.Name, path, ttl) + _ = e.cache.SetETag(ctx, remote.Name, path, etag, ttl) + result, err := e.serveFromStore(ctx, remote, path) + if err == nil { + result.Source = "cache" + go e.logAccess(remote.Name, path, true, result.Size, 0) + return result, nil + } + } + } + } + + start := time.Now() + result, err := e.fetchFromUpstream(ctx, remote, path, prov, class, ttl) + upstreamMS := int(time.Since(start).Milliseconds()) + if err != nil { + if remote.StaleOnError && isNetworkError(err) { + _ = e.cache.SetTTL(ctx, remote.Name, path, ttl) + stale, serr := e.serveFromStore(ctx, remote, path) + if serr == nil { + slog.Warn("serving stale on upstream error", "remote", remote.Name, "path", path, "error", err) + stale.Source = "cache" + go e.logAccess(remote.Name, path, true, stale.Size, 0) + return stale, nil + } + } + return nil, err + } + + go e.logAccess(remote.Name, path, false, result.Size, upstreamMS) + 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) { + url := prov.UpstreamURL(remote, path) + + authHeaders, err := prov.AuthHeaders(ctx, remote) + if err != nil { + return nil, fmt.Errorf("auth headers: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + for k, vv := range authHeaders { + for _, v := range vv { + req.Header.Add(k, v) + } + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, &UpstreamError{Err: err} + } + + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + return nil, &ProxyError{Status: resp.StatusCode, Message: fmt.Sprintf("upstream returned %d", resp.StatusCode)} + } + + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return nil, fmt.Errorf("read upstream body: %w", err) + } + + rewritten, err := prov.RewriteResponse(body, remote, "") + if err != nil { + return nil, fmt.Errorf("rewrite response: %w", err) + } + if rewritten != nil { + body = rewritten + } + + contentType := prov.ContentType(path) + if ct := resp.Header.Get("Content-Type"); ct != "" && contentType == "application/octet-stream" { + contentType = ct + } + + if class == ClassMutable { + s3Key := storage.IndexKey(remote.Name, path) + if err := e.store.Upload(ctx, s3Key, bytesReader(body), int64(len(body)), contentType); err != nil { + return nil, fmt.Errorf("upload index: %w", err) + } + + etag := resp.Header.Get("ETag") + _ = e.cache.SetTTL(ctx, remote.Name, path, ttl) + if etag != "" { + _ = e.cache.SetETag(ctx, remote.Name, path, etag, ttl) + } + } else { + hash := sha256Hash(body) + s3Key := storage.BlobKey(hash) + + exists, _ := e.store.Exists(ctx, s3Key) + if !exists { + if err := e.store.Upload(ctx, s3Key, bytesReader(body), int64(len(body)), contentType); err != nil { + return nil, fmt.Errorf("upload blob: %w", err) + } + } + + contentHash := fmt.Sprintf("sha256:%s", hash) + if err := e.db.UpsertBlob(ctx, contentHash, s3Key, int64(len(body)), contentType); err != nil { + slog.Warn("upsert blob failed", "error", err) + } + if err := e.db.UpsertArtifact(ctx, remote.Name, path, contentHash, resp.Header.Get("ETag")); err != nil { + slog.Warn("upsert artifact failed", "error", err) + } + + _ = e.cache.SetTTL(ctx, remote.Name, path, ttl) + if etag := resp.Header.Get("ETag"); etag != "" { + _ = e.cache.SetETag(ctx, remote.Name, path, etag, ttl) + } + } + + return &FetchResult{ + Reader: io.NopCloser(bytesReader(body)), + ContentType: contentType, + Size: int64(len(body)), + Source: "remote", + }, nil +} + +func (e *Engine) serveFromStore(ctx context.Context, remote models.Remote, path string) (*FetchResult, error) { + artifact, err := e.db.GetArtifact(ctx, remote.Name, path) + if err == nil && artifact != nil { + reader, info, err := e.store.Download(ctx, artifact.ContentHash[len("sha256:"):]) + if err == nil { + _ = e.db.TouchArtifactAccess(ctx, remote.Name, path) + return &FetchResult{ + Reader: reader, + ContentType: info.ContentType, + Size: info.Size, + }, nil + } + s3Key := storage.BlobKey(artifact.ContentHash[len("sha256:"):]) + reader, info, err = e.store.Download(ctx, s3Key) + if err == nil { + _ = e.db.TouchArtifactAccess(ctx, remote.Name, path) + return &FetchResult{ + Reader: reader, + ContentType: info.ContentType, + Size: info.Size, + }, nil + } + } + + s3Key := storage.IndexKey(remote.Name, path) + reader, info, err := e.store.Download(ctx, s3Key) + if err != nil { + return nil, fmt.Errorf("not in store: %w", err) + } + return &FetchResult{ + Reader: reader, + ContentType: info.ContentType, + Size: info.Size, + }, nil +} + +func (e *Engine) checkUpstream(ctx context.Context, remote models.Remote, path, etag string, prov provider.Provider) (bool, error) { + url := prov.UpstreamURL(remote, path) + + req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil) + if err != nil { + return false, err + } + req.Header.Set("If-None-Match", etag) + + authHeaders, err := prov.AuthHeaders(ctx, remote) + if err != nil { + return false, err + } + for k, vv := range authHeaders { + for _, v := range vv { + req.Header.Add(k, v) + } + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return false, &UpstreamError{Err: err} + } + resp.Body.Close() + + return resp.StatusCode == http.StatusNotModified, nil +} + +func (e *Engine) ttlFor(remote models.Remote, class Classification) time.Duration { + switch class { + case ClassImmutable: + if remote.ImmutableTTL == 0 { + return 0 + } + return time.Duration(remote.ImmutableTTL) * time.Second + default: + return time.Duration(remote.MutableTTL) * time.Second + } +} + +func (e *Engine) logAccess(remoteName, path string, cacheHit bool, size int64, upstreamMS int) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = e.db.InsertAccessLog(ctx, remoteName, path, cacheHit, size, upstreamMS, "") +} + +func sha256Hash(data []byte) string { + h := sha256.Sum256(data) + return hex.EncodeToString(h[:]) +} + +func bytesReader(data []byte) io.Reader { + return io.NewSectionReader(readerAt(data), 0, int64(len(data))) +} + +type readerAt []byte + +func (r readerAt) ReadAt(p []byte, off int64) (n int, err error) { + if off >= int64(len(r)) { + return 0, io.EOF + } + n = copy(p, r[off:]) + if off+int64(n) >= int64(len(r)) { + err = io.EOF + } + return +} + +type ProxyError struct { + Status int + Message string +} + +func (e *ProxyError) Error() string { return e.Message } + +type UpstreamError struct { + Err error +} + +func (e *UpstreamError) Error() string { return fmt.Sprintf("upstream error: %v", e.Err) } +func (e *UpstreamError) Unwrap() error { return e.Err } + +func isNetworkError(err error) bool { + if _, ok := err.(*UpstreamError); ok { + return true + } + return false +} diff --git a/internal/server/middleware.go b/internal/server/middleware.go new file mode 100644 index 0000000..a8ec248 --- /dev/null +++ b/internal/server/middleware.go @@ -0,0 +1,45 @@ +package server + +import ( + "log/slog" + "net/http" + "time" + + "github.com/go-chi/chi/v5/middleware" +) + +func cors(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) + }) +} + +func NewStructuredLogger() func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) + + defer func() { + slog.Info("request", + "method", r.Method, + "path", r.URL.Path, + "status", ww.Status(), + "bytes", ww.BytesWritten(), + "duration_ms", time.Since(start).Milliseconds(), + "remote", r.RemoteAddr, + "request_id", middleware.GetReqID(r.Context()), + ) + }() + + next.ServeHTTP(ww, r) + }) + } +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..f79da71 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,181 @@ +package server + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + + v1 "git.unkin.net/unkin/artifactapi/internal/api/v1" + v2 "git.unkin.net/unkin/artifactapi/internal/api/v2" + "git.unkin.net/unkin/artifactapi/internal/cache" + "git.unkin.net/unkin/artifactapi/internal/config" + "git.unkin.net/unkin/artifactapi/internal/database" + "git.unkin.net/unkin/artifactapi/internal/gc" + _ "git.unkin.net/unkin/artifactapi/internal/provider/alpine" + _ "git.unkin.net/unkin/artifactapi/internal/provider/docker" + _ "git.unkin.net/unkin/artifactapi/internal/provider/generic" + _ "git.unkin.net/unkin/artifactapi/internal/provider/goproxy" + _ "git.unkin.net/unkin/artifactapi/internal/provider/helm" + _ "git.unkin.net/unkin/artifactapi/internal/provider/npm" + _ "git.unkin.net/unkin/artifactapi/internal/provider/puppet" + _ "git.unkin.net/unkin/artifactapi/internal/provider/pypi" + _ "git.unkin.net/unkin/artifactapi/internal/provider/rpm" + _ "git.unkin.net/unkin/artifactapi/internal/provider/terraform" + "git.unkin.net/unkin/artifactapi/internal/proxy" + "git.unkin.net/unkin/artifactapi/internal/storage" + "git.unkin.net/unkin/artifactapi/internal/virtual" +) + +type Server struct { + cfg *config.Config + router chi.Router + db *database.DB + cache *cache.Redis + store *storage.S3 + engine *proxy.Engine + virtEngine *virtual.Engine + gc *gc.Collector +} + +func New(cfg *config.Config) (*Server, error) { + db, err := database.New(cfg.DatabaseDSN()) + if err != nil { + return nil, fmt.Errorf("database: %w", err) + } + + redis, err := cache.NewRedis(cfg.RedisURL) + if err != nil { + return nil, fmt.Errorf("redis: %w", err) + } + + s3, err := storage.NewS3(cfg.S3Endpoint, cfg.S3AccessKey, cfg.S3SecretKey, cfg.S3Bucket, cfg.S3Secure, cfg.S3Region) + if err != nil { + return nil, fmt.Errorf("s3: %w", err) + } + + engine := proxy.NewEngine(db, redis, s3) + virtEngine := virtual.NewEngine(db, engine) + collector := gc.New(db, s3, 1*time.Hour) + + s := &Server{ + cfg: cfg, + db: db, + cache: redis, + store: s3, + engine: engine, + virtEngine: virtEngine, + gc: collector, + } + + s.router = s.routes() + return s, nil +} + +func (s *Server) routes() chi.Router { + r := chi.NewRouter() + + r.Use(middleware.RequestID) + r.Use(middleware.RealIP) + r.Use(NewStructuredLogger()) + r.Use(middleware.Recoverer) + + r.Use(cors) + + r.Get("/health", s.handleHealth) + r.Get("/", s.handleRoot) + + proxyHandler := v1.NewProxyHandler(s.engine, s.virtEngine, s.db) + r.Mount("/api/v1", proxyHandler.Routes()) + + remotesHandler := v2.NewRemotesHandler(s.db) + virtualsHandler := v2.NewVirtualsHandler(s.db) + healthHandler := v2.NewHealthHandler(s.db, s.cache, s.store) + statsHandler := v2.NewStatsHandler(s.db) + eventsHandler := v2.NewEventsHandler() + probeHandler := v2.NewProbeHandler(s.engine, s.db) + + r.Route("/api/v2", func(r chi.Router) { + r.Mount("/remotes", remotesHandler.Routes()) + r.Mount("/virtuals", virtualsHandler.Routes()) + r.Mount("/health", healthHandler.Routes()) + r.Mount("/stats", statsHandler.Routes()) + r.Mount("/events", eventsHandler.Routes()) + r.Mount("/probe", probeHandler.Routes()) + + r.Route("/remotes/{name}/objects", func(r chi.Router) { + objHandler := v2.NewObjectsHandler(s.db) + r.Get("/", objHandler.Routes().ServeHTTP) + r.Delete("/*", objHandler.Routes().ServeHTTP) + }) + }) + + return r +} + +func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `{"status":"ok"}`) +} + +func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `{"name":"artifactapi","version":"3.0.0-dev"}`) +} + +func (s *Server) newHTTPServer() *http.Server { + return &http.Server{ + Addr: s.cfg.ListenAddr, + Handler: s.router, + ReadTimeout: 30 * time.Second, + WriteTimeout: 300 * time.Second, + IdleTimeout: 120 * time.Second, + } +} + +func (s *Server) Run(ctx context.Context) error { + go s.gc.Run(ctx) + + httpServer := s.newHTTPServer() + + go func() { + <-ctx.Done() + slog.Info("shutting down server") + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = httpServer.Shutdown(shutdownCtx) + }() + + slog.Info("starting server", "addr", s.cfg.ListenAddr) + if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + return err + } + return nil +} + +func (s *Server) RunOnListener(ctx context.Context, ln net.Listener) error { + go s.gc.Run(ctx) + + httpServer := s.newHTTPServer() + + go func() { + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = httpServer.Shutdown(shutdownCtx) + }() + + slog.Info("starting server", "addr", ln.Addr().String()) + if err := httpServer.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) { + return err + } + return nil +} diff --git a/internal/storage/cas.go b/internal/storage/cas.go new file mode 100644 index 0000000..4f5d813 --- /dev/null +++ b/internal/storage/cas.go @@ -0,0 +1,72 @@ +package storage + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" +) + +type CAS struct { + s3 *S3 +} + +func NewCAS(s3 *S3) *CAS { + return &CAS{s3: s3} +} + +type CASResult struct { + ContentHash string + S3Key string + SizeBytes int64 + AlreadyExists bool +} + +func (c *CAS) Store(ctx context.Context, reader io.Reader, contentType string) (*CASResult, error) { + tmp, err := os.CreateTemp("", "artifact-*") + if err != nil { + return nil, fmt.Errorf("create temp file: %w", err) + } + defer os.Remove(tmp.Name()) + defer tmp.Close() + + hasher := sha256.New() + size, err := io.Copy(io.MultiWriter(tmp, hasher), reader) + if err != nil { + return nil, fmt.Errorf("write temp file: %w", err) + } + + hash := hex.EncodeToString(hasher.Sum(nil)) + s3Key := BlobKey(hash) + + exists, err := c.s3.Exists(ctx, s3Key) + if err != nil { + return nil, fmt.Errorf("check blob exists: %w", err) + } + + if !exists { + if _, err := tmp.Seek(0, io.SeekStart); err != nil { + return nil, fmt.Errorf("seek temp file: %w", err) + } + if err := c.s3.Upload(ctx, s3Key, tmp, size, contentType); err != nil { + return nil, fmt.Errorf("upload blob: %w", err) + } + } + + return &CASResult{ + ContentHash: fmt.Sprintf("sha256:%s", hash), + S3Key: s3Key, + SizeBytes: size, + AlreadyExists: exists, + }, nil +} + +func BlobKey(hash string) string { + return fmt.Sprintf("blobs/sha256/%s", hash) +} + +func IndexKey(remote, path string) string { + return fmt.Sprintf("indexes/%s/%s", remote, path) +} diff --git a/internal/storage/s3.go b/internal/storage/s3.go new file mode 100644 index 0000000..72276a2 --- /dev/null +++ b/internal/storage/s3.go @@ -0,0 +1,99 @@ +package storage + +import ( + "context" + "fmt" + "io" + "log/slog" + + "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/credentials" +) + +type S3 struct { + client *minio.Client + bucket string +} + +func NewS3(endpoint, accessKey, secretKey, bucket string, secure bool, region string) (*S3, error) { + opts := &minio.Options{ + Creds: credentials.NewStaticV4(accessKey, secretKey, ""), + Secure: secure, + } + if region != "" { + opts.Region = region + } + + client, err := minio.New(endpoint, opts) + if err != nil { + return nil, fmt.Errorf("create s3 client: %w", err) + } + + s := &S3{client: client, bucket: bucket} + + if err := s.ensureBucket(context.Background()); err != nil { + return nil, err + } + + return s, nil +} + +func (s *S3) ensureBucket(ctx context.Context) error { + exists, err := s.client.BucketExists(ctx, s.bucket) + if err != nil { + return fmt.Errorf("check bucket: %w", err) + } + if !exists { + if err := s.client.MakeBucket(ctx, s.bucket, minio.MakeBucketOptions{}); err != nil { + return fmt.Errorf("create bucket: %w", err) + } + slog.Info("created bucket", "bucket", s.bucket) + } + return nil +} + +func (s *S3) Upload(ctx context.Context, key string, reader io.Reader, size int64, contentType string) error { + _, err := s.client.PutObject(ctx, s.bucket, key, reader, size, minio.PutObjectOptions{ + ContentType: contentType, + }) + return err +} + +func (s *S3) Download(ctx context.Context, key string) (io.ReadCloser, *minio.ObjectInfo, error) { + obj, err := s.client.GetObject(ctx, s.bucket, key, minio.GetObjectOptions{}) + if err != nil { + return nil, nil, err + } + + info, err := obj.Stat() + if err != nil { + obj.Close() + return nil, nil, err + } + + return obj, &info, nil +} + +func (s *S3) Exists(ctx context.Context, key string) (bool, error) { + _, err := s.client.StatObject(ctx, s.bucket, key, minio.StatObjectOptions{}) + if err != nil { + resp := minio.ToErrorResponse(err) + if resp.Code == "NoSuchKey" { + return false, nil + } + return false, err + } + return true, nil +} + +func (s *S3) Delete(ctx context.Context, key string) error { + return s.client.RemoveObject(ctx, s.bucket, key, minio.RemoveObjectOptions{}) +} + +func (s *S3) Stat(ctx context.Context, key string) (*minio.ObjectInfo, error) { + info, err := s.client.StatObject(ctx, s.bucket, key, minio.StatObjectOptions{}) + if err != nil { + return nil, err + } + return &info, nil +} diff --git a/internal/tui/app.go b/internal/tui/app.go new file mode 100644 index 0000000..0d82f95 --- /dev/null +++ b/internal/tui/app.go @@ -0,0 +1,305 @@ +package tui + +import ( + "context" + "fmt" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "git.unkin.net/unkin/artifactapi/pkg/client" + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +type view int + +const ( + viewDashboard view = iota + viewRemotes + viewRemoteDetail + viewObjects + viewVirtuals +) + +type model struct { + client *client.Client + view view + width int + height int + err error + loading bool + + stats *models.OverviewStats + remotes []models.Remote + virtuals []models.Virtual + objects []models.Artifact + + selectedRemote string + cursor int + page int +} + +func New(endpoint string) *model { + return &model{ + client: client.New(endpoint), + view: viewDashboard, + loading: true, + page: 1, + } +} + +func (m *model) Run() error { + p := tea.NewProgram(m, tea.WithAltScreen()) + _, err := p.Run() + return err +} + +func (m *model) Init() tea.Cmd { + return m.loadDashboard() +} + +type dashboardLoaded struct { + stats *models.OverviewStats + remotes []models.Remote + virtuals []models.Virtual +} + +type remotesLoaded struct{ remotes []models.Remote } +type virtualsLoaded struct{ virtuals []models.Virtual } +type objectsLoaded struct{ objects []models.Artifact } +type errMsg struct{ err error } + +func (m *model) loadDashboard() tea.Cmd { + return func() tea.Msg { + ctx := context.Background() + stats, err := m.client.Stats(ctx) + if err != nil { + return errMsg{err} + } + remotes, _ := m.client.ListRemotes(ctx) + virtuals, _ := m.client.ListVirtuals(ctx) + return dashboardLoaded{stats: stats, remotes: remotes, virtuals: virtuals} + } +} + +func (m *model) loadRemotes() tea.Cmd { + return func() tea.Msg { + remotes, err := m.client.ListRemotes(context.Background()) + if err != nil { + return errMsg{err} + } + return remotesLoaded{remotes} + } +} + +func (m *model) loadVirtuals() tea.Cmd { + return func() tea.Msg { + virtuals, err := m.client.ListVirtuals(context.Background()) + if err != nil { + return errMsg{err} + } + return virtualsLoaded{virtuals} + } +} + +func (m *model) loadObjects() tea.Cmd { + return func() tea.Msg { + objects, err := m.client.ListObjects(context.Background(), m.selectedRemote, m.page, 30) + if err != nil { + return errMsg{err} + } + return objectsLoaded{objects} + } +} + +func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + + case tea.KeyMsg: + return m.handleKey(msg) + + case dashboardLoaded: + m.loading = false + m.stats = msg.stats + m.remotes = msg.remotes + m.virtuals = msg.virtuals + return m, nil + + case remotesLoaded: + m.loading = false + m.remotes = msg.remotes + m.cursor = 0 + return m, nil + + case virtualsLoaded: + m.loading = false + m.virtuals = msg.virtuals + m.cursor = 0 + return m, nil + + case objectsLoaded: + m.loading = false + m.objects = msg.objects + m.cursor = 0 + return m, nil + + case errMsg: + m.loading = false + m.err = msg.err + return m, nil + } + + return m, nil +} + +func (m *model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "q", "ctrl+c": + if m.view == viewDashboard { + return m, tea.Quit + } + m.view = viewDashboard + m.cursor = 0 + m.loading = true + return m, m.loadDashboard() + + case "esc": + switch m.view { + case viewRemoteDetail, viewObjects: + m.view = viewRemotes + m.cursor = 0 + m.loading = true + return m, m.loadRemotes() + case viewRemotes, viewVirtuals: + m.view = viewDashboard + m.cursor = 0 + m.loading = true + return m, m.loadDashboard() + default: + return m, tea.Quit + } + + case "1": + m.view = viewDashboard + m.loading = true + return m, m.loadDashboard() + + case "2": + m.view = viewRemotes + m.loading = true + return m, m.loadRemotes() + + case "3": + m.view = viewVirtuals + m.loading = true + return m, m.loadVirtuals() + + case "j", "down": + m.cursor++ + m.clampCursor() + return m, nil + + case "k", "up": + if m.cursor > 0 { + m.cursor-- + } + return m, nil + + case "enter": + return m.handleEnter() + + case "r": + m.loading = true + switch m.view { + case viewDashboard: + return m, m.loadDashboard() + case viewRemotes: + return m, m.loadRemotes() + case viewVirtuals: + return m, m.loadVirtuals() + case viewObjects: + return m, m.loadObjects() + } + } + + return m, nil +} + +func (m *model) handleEnter() (tea.Model, tea.Cmd) { + switch m.view { + case viewRemotes: + if m.cursor < len(m.remotes) { + m.selectedRemote = m.remotes[m.cursor].Name + m.view = viewRemoteDetail + return m, nil + } + case viewRemoteDetail: + m.view = viewObjects + m.page = 1 + m.loading = true + return m, m.loadObjects() + } + return m, nil +} + +func (m *model) clampCursor() { + max := 0 + switch m.view { + case viewRemotes: + max = len(m.remotes) - 1 + case viewVirtuals: + max = len(m.virtuals) - 1 + case viewObjects: + max = len(m.objects) - 1 + } + if m.cursor > max { + m.cursor = max + } + if m.cursor < 0 { + m.cursor = 0 + } +} + +func (m *model) View() string { + if m.loading { + return m.chrome("Loading...") + } + if m.err != nil { + return m.chrome(errStyle.Render(fmt.Sprintf("Error: %v", m.err))) + } + + var body string + switch m.view { + case viewDashboard: + body = m.viewDashboard() + case viewRemotes: + body = m.viewRemotesList() + case viewRemoteDetail: + body = m.viewRemoteDetail() + case viewObjects: + body = m.viewObjectsList() + case viewVirtuals: + body = m.viewVirtualsList() + } + + return m.chrome(body) +} + +func (m *model) chrome(body string) string { + nav := navStyle.Render( + "[1] Dashboard [2] Remotes [3] Virtuals │ [r] Refresh [q] Quit", + ) + return lipgloss.JoinVertical(lipgloss.Left, body, "", nav) +} + +var ( + titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")) + navStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + errStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9")) + mutedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + selStyle = lipgloss.NewStyle().Background(lipgloss.Color("4")).Foreground(lipgloss.Color("15")) +) diff --git a/internal/tui/render.go b/internal/tui/render.go new file mode 100644 index 0000000..67d7298 --- /dev/null +++ b/internal/tui/render.go @@ -0,0 +1,140 @@ +package tui + +import ( + "fmt" + "strings" + + "git.unkin.net/unkin/artifactapi/internal/tui/views" + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +func (m *model) viewDashboard() string { + return titleStyle.Render("ArtifactAPI Dashboard") + "\n\n" + + views.RenderDashboard(m.stats, len(m.remotes), len(m.virtuals)) + + "\n\n" + mutedStyle.Render("Press [2] for remotes, [3] for virtuals") +} + +func (m *model) viewRemotesList() string { + var sb strings.Builder + sb.WriteString(titleStyle.Render("Remotes") + "\n\n") + + if len(m.remotes) == 0 { + sb.WriteString(mutedStyle.Render("No remotes configured")) + return sb.String() + } + + for i, r := range m.remotes { + line := fmt.Sprintf(" %-25s %-12s %s", r.Name, r.PackageType, r.Description) + if i == m.cursor { + sb.WriteString(selStyle.Render(line)) + } else { + sb.WriteString(line) + } + sb.WriteString("\n") + } + + sb.WriteString("\n" + mutedStyle.Render("j/k navigate · enter detail · esc back")) + return sb.String() +} + +func (m *model) viewRemoteDetail() string { + var r *remoteView + for i := range m.remotes { + if m.remotes[i].Name == m.selectedRemote { + r = &remoteView{m.remotes[i]} + break + } + } + if r == nil { + return mutedStyle.Render("Remote not found") + } + + var sb strings.Builder + sb.WriteString(titleStyle.Render(r.Name) + "\n\n") + sb.WriteString(fmt.Sprintf(" Type: %s\n", r.PackageType)) + sb.WriteString(fmt.Sprintf(" Base URL: %s\n", r.BaseURL)) + sb.WriteString(fmt.Sprintf(" Description: %s\n", r.Description)) + sb.WriteString(fmt.Sprintf(" Immutable TTL: %s\n", ttlStr(r.ImmutableTTL))) + sb.WriteString(fmt.Sprintf(" Mutable TTL: %ds\n", r.MutableTTL)) + sb.WriteString(fmt.Sprintf(" Revalidation: %v\n", r.CheckMutable)) + sb.WriteString(fmt.Sprintf(" Stale on Error: %v\n", r.StaleOnError)) + + if len(r.Patterns) > 0 { + sb.WriteString(fmt.Sprintf(" Patterns: %s\n", strings.Join(r.Patterns, ", "))) + } + if len(r.Blocklist) > 0 { + sb.WriteString(fmt.Sprintf(" Blocklist: %s\n", strings.Join(r.Blocklist, ", "))) + } + if r.ManagedBy != "" { + sb.WriteString(fmt.Sprintf(" Managed by: %s\n", r.ManagedBy)) + } + + sb.WriteString("\n" + mutedStyle.Render("enter → browse objects · esc back")) + return sb.String() +} + +func (m *model) viewObjectsList() string { + var sb strings.Builder + sb.WriteString(titleStyle.Render(fmt.Sprintf("Objects: %s (page %d)", m.selectedRemote, m.page)) + "\n\n") + + if len(m.objects) == 0 { + sb.WriteString(mutedStyle.Render("No cached objects")) + return sb.String() + } + + for i, a := range m.objects { + size := views.FormatBytes(a.SizeBytes) + line := fmt.Sprintf(" %-50s %10s %5d hits", truncate(a.Path, 50), size, a.AccessCount) + if i == m.cursor { + sb.WriteString(selStyle.Render(line)) + } else { + sb.WriteString(line) + } + sb.WriteString("\n") + } + + sb.WriteString("\n" + mutedStyle.Render("j/k navigate · esc back")) + return sb.String() +} + +func (m *model) viewVirtualsList() string { + var sb strings.Builder + sb.WriteString(titleStyle.Render("Virtual Repositories") + "\n\n") + + if len(m.virtuals) == 0 { + sb.WriteString(mutedStyle.Render("No virtual repositories configured")) + return sb.String() + } + + for i, v := range m.virtuals { + line := fmt.Sprintf(" %-25s %-12s %d members %s", + v.Name, v.PackageType, len(v.Members), v.Description) + if i == m.cursor { + sb.WriteString(selStyle.Render(line)) + } else { + sb.WriteString(line) + } + sb.WriteString("\n") + } + + sb.WriteString("\n" + mutedStyle.Render("j/k navigate · esc back")) + return sb.String() +} + +type remoteView struct { + models.Remote +} + +func ttlStr(ttl int) string { + if ttl == 0 { + return "forever" + } + return fmt.Sprintf("%ds", ttl) +} + +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + return s[:max-3] + "..." +} diff --git a/internal/tui/views/dashboard.go b/internal/tui/views/dashboard.go new file mode 100644 index 0000000..f7b0f8c --- /dev/null +++ b/internal/tui/views/dashboard.go @@ -0,0 +1,45 @@ +package views + +import ( + "fmt" + + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +func FormatBytes(bytes int64) string { + if bytes == 0 { + return "0 B" + } + units := []string{"B", "KB", "MB", "GB", "TB"} + i := 0 + b := float64(bytes) + for b >= 1024 && i < len(units)-1 { + b /= 1024 + i++ + } + if i == 0 { + return fmt.Sprintf("%.0f %s", b, units[i]) + } + return fmt.Sprintf("%.1f %s", b, units[i]) +} + +func RenderDashboard(stats *models.OverviewStats, remoteCount, virtualCount int) string { + if stats == nil { + return "No stats available" + } + + return fmt.Sprintf( + "╭─ Dashboard ──────────────────────────────╮\n"+ + "│ Remotes: %-24d│\n"+ + "│ Cached Objects: %-24d│\n"+ + "│ Storage Used: %-24s│\n"+ + "│ Dedup Savings: %-20d blobs │\n"+ + "│ Virtuals: %-24d│\n"+ + "╰──────────────────────────────────────────╯", + stats.TotalRemotes, + stats.TotalObjects, + FormatBytes(stats.TotalBytes), + stats.TotalBlobsDeduped, + virtualCount, + ) +} diff --git a/internal/virtual/engine.go b/internal/virtual/engine.go new file mode 100644 index 0000000..85928e3 --- /dev/null +++ b/internal/virtual/engine.go @@ -0,0 +1,111 @@ +package virtual + +import ( + "context" + "fmt" + "io" + "log/slog" + "sync" + + "git.unkin.net/unkin/artifactapi/internal/database" + "git.unkin.net/unkin/artifactapi/internal/provider" + "git.unkin.net/unkin/artifactapi/internal/proxy" + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +type Engine struct { + db *database.DB + proxyEngine *proxy.Engine +} + +func NewEngine(db *database.DB, proxyEngine *proxy.Engine) *Engine { + return &Engine{db: db, proxyEngine: proxyEngine} +} + +func (e *Engine) Fetch(ctx context.Context, virt models.Virtual, path string, proxyBaseURL string) ([]byte, string, error) { + merger, err := GetMerger(virt.PackageType) + if err != nil { + return nil, "", fmt.Errorf("unsupported virtual type %q: %w", virt.PackageType, err) + } + + members, err := e.fetchMemberIndexes(ctx, virt, path) + if err != nil { + return nil, "", err + } + + if len(members) == 0 { + return nil, "", fmt.Errorf("no members reachable for virtual %q", virt.Name) + } + + merged, err := merger.MergeIndexes(members, proxyBaseURL) + if err != nil { + return nil, "", fmt.Errorf("merge indexes: %w", err) + } + + contentType := "application/octet-stream" + switch virt.PackageType { + case models.PackageHelm: + contentType = "text/yaml" + case models.PackagePyPI: + contentType = "text/html" + } + + return merged, contentType, nil +} + +func (e *Engine) fetchMemberIndexes(ctx context.Context, virt models.Virtual, path string) ([]MemberIndex, error) { + type result struct { + index MemberIndex + err error + } + + results := make([]result, len(virt.Members)) + var wg sync.WaitGroup + + for i, memberName := range virt.Members { + wg.Add(1) + go func(idx int, name string) { + defer wg.Done() + + remote, err := e.db.GetRemote(ctx, name) + if err != nil { + results[idx] = result{err: fmt.Errorf("remote %q: %w", name, err)} + return + } + + prov, err := provider.Get(remote.PackageType) + if err != nil { + results[idx] = result{err: fmt.Errorf("provider %q: %w", remote.PackageType, err)} + return + } + + fetchResult, err := e.proxyEngine.Fetch(ctx, *remote, path, prov) + if err != nil { + results[idx] = result{err: fmt.Errorf("fetch %q/%s: %w", name, path, err)} + return + } + defer fetchResult.Reader.Close() + + body, err := io.ReadAll(fetchResult.Reader) + if err != nil { + results[idx] = result{err: fmt.Errorf("read %q: %w", name, err)} + return + } + + results[idx] = result{index: MemberIndex{RemoteName: name, Body: body}} + }(i, memberName) + } + + wg.Wait() + + var members []MemberIndex + for _, r := range results { + if r.err != nil { + slog.Warn("virtual member fetch failed", "error", r.err) + continue + } + members = append(members, r.index) + } + + return members, nil +} diff --git a/internal/virtual/helm_merger.go b/internal/virtual/helm_merger.go new file mode 100644 index 0000000..70a47e7 --- /dev/null +++ b/internal/virtual/helm_merger.go @@ -0,0 +1,92 @@ +package virtual + +import ( + "fmt" + "strings" + + "gopkg.in/yaml.v3" + + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +func init() { + RegisterMerger(models.PackageHelm, &HelmMerger{}) +} + +type HelmMerger struct{} + +type helmIndex struct { + APIVersion string `yaml:"apiVersion"` + Entries map[string][]helmChartVersion `yaml:"entries"` + Generated string `yaml:"generated,omitempty"` +} + +type helmChartVersion struct { + Name string `yaml:"name"` + Version string `yaml:"version"` + URLs []string `yaml:"urls"` + rest map[string]any +} + +func (m *HelmMerger) MergeIndexes(members []MemberIndex, proxyBaseURL string) ([]byte, error) { + merged := &helmIndex{ + APIVersion: "v1", + Entries: make(map[string][]helmChartVersion), + } + + seen := map[string]map[string]bool{} + + for _, member := range members { + var idx helmIndex + if err := yaml.Unmarshal(member.Body, &idx); err != nil { + continue + } + + for chart, versions := range idx.Entries { + if seen[chart] == nil { + seen[chart] = map[string]bool{} + } + for _, ver := range versions { + key := chart + ":" + ver.Version + if seen[chart][ver.Version] { + continue + } + seen[chart][ver.Version] = true + + if proxyBaseURL != "" { + for i, u := range ver.URLs { + if strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://") { + ver.URLs[i] = fmt.Sprintf("%s/api/v1/remote/%s/%s", + strings.TrimRight(proxyBaseURL, "/"), + member.RemoteName, + extractPath(u)) + } else { + ver.URLs[i] = fmt.Sprintf("%s/api/v1/remote/%s/%s", + strings.TrimRight(proxyBaseURL, "/"), + member.RemoteName, + u) + } + } + } + + merged.Entries[chart] = append(merged.Entries[chart], ver) + _ = key + } + } + } + + return yaml.Marshal(merged) +} + +func extractPath(rawURL string) string { + idx := strings.Index(rawURL, "://") + if idx == -1 { + return rawURL + } + rest := rawURL[idx+3:] + slashIdx := strings.Index(rest, "/") + if slashIdx == -1 { + return "" + } + return rest[slashIdx+1:] +} diff --git a/internal/virtual/helm_merger_test.go b/internal/virtual/helm_merger_test.go new file mode 100644 index 0000000..857074d --- /dev/null +++ b/internal/virtual/helm_merger_test.go @@ -0,0 +1,124 @@ +package virtual_test + +import ( + "strings" + "testing" + + "git.unkin.net/unkin/artifactapi/internal/virtual" +) + +func TestHelmMerger_BasicMerge(t *testing.T) { + m := &virtual.HelmMerger{} + + member1 := virtual.MemberIndex{ + RemoteName: "repo-a", + Body: []byte(`apiVersion: v1 +entries: + nginx: + - name: nginx + version: "1.0.0" + urls: + - https://charts-a.example.com/nginx-1.0.0.tgz +`), + } + + member2 := virtual.MemberIndex{ + RemoteName: "repo-b", + Body: []byte(`apiVersion: v1 +entries: + redis: + - name: redis + version: "2.0.0" + urls: + - https://charts-b.example.com/redis-2.0.0.tgz +`), + } + + result, err := m.MergeIndexes([]virtual.MemberIndex{member1, member2}, "https://proxy.example.com") + if err != nil { + t.Fatal(err) + } + + body := string(result) + if !strings.Contains(body, "nginx") { + t.Error("expected nginx in merged index") + } + if !strings.Contains(body, "redis") { + t.Error("expected redis in merged index") + } + if !strings.Contains(body, "proxy.example.com/api/v1/remote/repo-a") { + t.Error("expected proxy URL for repo-a") + } + if !strings.Contains(body, "proxy.example.com/api/v1/remote/repo-b") { + t.Error("expected proxy URL for repo-b") + } +} + +func TestHelmMerger_Dedup(t *testing.T) { + m := &virtual.HelmMerger{} + + idx := []byte(`apiVersion: v1 +entries: + nginx: + - name: nginx + version: "1.0.0" + urls: + - nginx-1.0.0.tgz +`) + + members := []virtual.MemberIndex{ + {RemoteName: "repo-a", Body: idx}, + {RemoteName: "repo-b", Body: idx}, + } + + result, err := m.MergeIndexes(members, "") + if err != nil { + t.Fatal(err) + } + + count := strings.Count(string(result), "name: nginx") + if count != 1 { + t.Errorf("expected 1 entry for nginx, got %d\n%s", count, result) + } +} + +func TestHelmMerger_PriorityOrder(t *testing.T) { + m := &virtual.HelmMerger{} + + member1 := virtual.MemberIndex{ + RemoteName: "priority-repo", + Body: []byte(`apiVersion: v1 +entries: + chart: + - name: chart + version: "1.0.0" + urls: + - chart-from-priority.tgz +`), + } + + member2 := virtual.MemberIndex{ + RemoteName: "fallback-repo", + Body: []byte(`apiVersion: v1 +entries: + chart: + - name: chart + version: "1.0.0" + urls: + - chart-from-fallback.tgz +`), + } + + result, err := m.MergeIndexes([]virtual.MemberIndex{member1, member2}, "https://proxy") + if err != nil { + t.Fatal(err) + } + + body := string(result) + if !strings.Contains(body, "priority-repo") { + t.Error("expected priority repo URL to win") + } + if strings.Contains(body, "fallback-repo") { + t.Error("expected fallback repo to be excluded for duplicate") + } +} diff --git a/internal/virtual/merger.go b/internal/virtual/merger.go new file mode 100644 index 0000000..333c399 --- /dev/null +++ b/internal/virtual/merger.go @@ -0,0 +1,30 @@ +package virtual + +import ( + "fmt" + + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +type MemberIndex struct { + RemoteName string + Body []byte +} + +type IndexMerger interface { + MergeIndexes(members []MemberIndex, proxyBaseURL string) ([]byte, error) +} + +var mergers = map[models.PackageType]IndexMerger{} + +func RegisterMerger(pt models.PackageType, m IndexMerger) { + mergers[pt] = m +} + +func GetMerger(pt models.PackageType) (IndexMerger, error) { + m, ok := mergers[pt] + if !ok { + return nil, fmt.Errorf("no merger registered for package type %q", pt) + } + return m, nil +} diff --git a/internal/virtual/pypi_merger.go b/internal/virtual/pypi_merger.go new file mode 100644 index 0000000..a2ec737 --- /dev/null +++ b/internal/virtual/pypi_merger.go @@ -0,0 +1,89 @@ +package virtual + +import ( + "fmt" + "sort" + "strings" + + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +func init() { + RegisterMerger(models.PackagePyPI, &PyPIMerger{}) +} + +type PyPIMerger struct{} + +func (m *PyPIMerger) MergeIndexes(members []MemberIndex, proxyBaseURL string) ([]byte, error) { + links := map[string]string{} + + for _, member := range members { + body := string(member.Body) + for _, line := range strings.Split(body, "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "\n\n") + for _, name := range keys { + sb.WriteString(fmt.Sprintf(" %s\n", links[name], name)) + } + sb.WriteString("\n") + + return []byte(sb.String()), nil +} + +func extractHref(tag string) string { + idx := strings.Index(tag, `href="`) + if idx == -1 { + return "" + } + rest := tag[idx+6:] + end := strings.Index(rest, `"`) + if end == -1 { + return rest + } + return rest[:end] +} + +func extractLinkText(tag string) string { + start := strings.Index(tag, ">") + if start == -1 { + return "" + } + rest := tag[start+1:] + end := strings.Index(rest, "") + if end == -1 { + return strings.TrimSpace(rest) + } + return strings.TrimSpace(rest[:end]) +} diff --git a/internal/virtual/pypi_merger_test.go b/internal/virtual/pypi_merger_test.go new file mode 100644 index 0000000..58eaf6d --- /dev/null +++ b/internal/virtual/pypi_merger_test.go @@ -0,0 +1,98 @@ +package virtual_test + +import ( + "strings" + "testing" + + "git.unkin.net/unkin/artifactapi/internal/virtual" +) + +func TestPyPIMerger_BasicMerge(t *testing.T) { + m := &virtual.PyPIMerger{} + + member1 := virtual.MemberIndex{ + RemoteName: "pypi-a", + Body: []byte(` + + requests + flask +`), + } + + member2 := virtual.MemberIndex{ + RemoteName: "pypi-b", + Body: []byte(` + + django +`), + } + + result, err := m.MergeIndexes([]virtual.MemberIndex{member1, member2}, "https://proxy.example.com") + if err != nil { + t.Fatal(err) + } + + body := string(result) + if !strings.Contains(body, "requests") { + t.Error("expected requests") + } + if !strings.Contains(body, "flask") { + t.Error("expected flask") + } + if !strings.Contains(body, "django") { + t.Error("expected django") + } + if !strings.Contains(body, "proxy.example.com/api/v1/remote/pypi-a") { + t.Error("expected proxy URL for pypi-a") + } +} + +func TestPyPIMerger_Dedup(t *testing.T) { + m := &virtual.PyPIMerger{} + + idx := []byte(` + requests +`) + + members := []virtual.MemberIndex{ + {RemoteName: "a", Body: idx}, + {RemoteName: "b", Body: idx}, + } + + result, err := m.MergeIndexes(members, "") + if err != nil { + t.Fatal(err) + } + + count := strings.Count(string(result), " tag for deduplicated requests, got %d\n%s", count, result) + } +} + +func TestPyPIMerger_Sorted(t *testing.T) { + m := &virtual.PyPIMerger{} + + member := virtual.MemberIndex{ + RemoteName: "pypi", + Body: []byte(` + zebra + alpha + middle +`), + } + + result, err := m.MergeIndexes([]virtual.MemberIndex{member}, "") + if err != nil { + t.Fatal(err) + } + + body := string(result) + alphaIdx := strings.Index(body, "alpha") + middleIdx := strings.Index(body, "middle") + zebraIdx := strings.Index(body, "zebra") + + if alphaIdx > middleIdx || middleIdx > zebraIdx { + t.Error("expected sorted output") + } +} diff --git a/pkg/client/client.go b/pkg/client/client.go new file mode 100644 index 0000000..ca6440a --- /dev/null +++ b/pkg/client/client.go @@ -0,0 +1,76 @@ +package client + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" +) + +type Client struct { + baseURL string + httpClient *http.Client +} + +func New(baseURL string) *Client { + return &Client{ + baseURL: baseURL, + httpClient: http.DefaultClient, + } +} + +func (c *Client) get(ctx context.Context, path string, out any) error { + return c.do(ctx, http.MethodGet, path, nil, out) +} + +func (c *Client) post(ctx context.Context, path string, body any, out any) error { + return c.do(ctx, http.MethodPost, path, body, out) +} + +func (c *Client) put(ctx context.Context, path string, body any, out any) error { + return c.do(ctx, http.MethodPut, path, body, out) +} + +func (c *Client) delete(ctx context.Context, path string) error { + return c.do(ctx, http.MethodDelete, path, nil, nil) +} + +func (c *Client) do(ctx context.Context, method, path string, body any, out any) error { + var bodyReader io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("marshal: %w", err) + } + bodyReader = bytes.NewReader(b) + } + + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bodyReader) + if err != nil { + return fmt.Errorf("request: %w", err) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("do: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + b, _ := io.ReadAll(resp.Body) + return fmt.Errorf("api error %d: %s", resp.StatusCode, b) + } + + if out != nil && resp.StatusCode != http.StatusNoContent { + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + return fmt.Errorf("decode: %w", err) + } + } + + return nil +} diff --git a/pkg/client/remotes.go b/pkg/client/remotes.go new file mode 100644 index 0000000..37eb32f --- /dev/null +++ b/pkg/client/remotes.go @@ -0,0 +1,32 @@ +package client + +import ( + "context" + "fmt" + + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +func (c *Client) ListRemotes(ctx context.Context) ([]models.Remote, error) { + var remotes []models.Remote + err := c.get(ctx, "/api/v2/remotes", &remotes) + return remotes, err +} + +func (c *Client) GetRemote(ctx context.Context, name string) (*models.Remote, error) { + var remote models.Remote + err := c.get(ctx, fmt.Sprintf("/api/v2/remotes/%s", name), &remote) + return &remote, err +} + +func (c *Client) CreateRemote(ctx context.Context, r *models.Remote) error { + return c.post(ctx, "/api/v2/remotes", r, r) +} + +func (c *Client) UpdateRemote(ctx context.Context, r *models.Remote) error { + return c.put(ctx, fmt.Sprintf("/api/v2/remotes/%s", r.Name), r, r) +} + +func (c *Client) DeleteRemote(ctx context.Context, name string) error { + return c.delete(ctx, fmt.Sprintf("/api/v2/remotes/%s", name)) +} diff --git a/pkg/client/stats.go b/pkg/client/stats.go new file mode 100644 index 0000000..c7cbfcf --- /dev/null +++ b/pkg/client/stats.go @@ -0,0 +1,30 @@ +package client + +import ( + "context" + "fmt" + + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +func (c *Client) Stats(ctx context.Context) (*models.OverviewStats, error) { + var stats models.OverviewStats + err := c.get(ctx, "/api/v2/stats", &stats) + return &stats, err +} + +func (c *Client) Health(ctx context.Context) (*models.RemoteHealth, error) { + var health models.RemoteHealth + err := c.get(ctx, "/api/v2/health", &health) + return &health, err +} + +func (c *Client) ListObjects(ctx context.Context, remote string, page, perPage int) ([]models.Artifact, error) { + var artifacts []models.Artifact + err := c.get(ctx, fmt.Sprintf("/api/v2/remotes/%s/objects?page=%d&per_page=%d", remote, page, perPage), &artifacts) + return artifacts, err +} + +func (c *Client) EvictObject(ctx context.Context, remote, path string) error { + return c.delete(ctx, fmt.Sprintf("/api/v2/remotes/%s/objects/%s", remote, path)) +} diff --git a/pkg/client/virtuals.go b/pkg/client/virtuals.go new file mode 100644 index 0000000..6b2f4fd --- /dev/null +++ b/pkg/client/virtuals.go @@ -0,0 +1,32 @@ +package client + +import ( + "context" + "fmt" + + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +func (c *Client) ListVirtuals(ctx context.Context) ([]models.Virtual, error) { + var virtuals []models.Virtual + err := c.get(ctx, "/api/v2/virtuals", &virtuals) + return virtuals, err +} + +func (c *Client) GetVirtual(ctx context.Context, name string) (*models.Virtual, error) { + var virt models.Virtual + err := c.get(ctx, fmt.Sprintf("/api/v2/virtuals/%s", name), &virt) + return &virt, err +} + +func (c *Client) CreateVirtual(ctx context.Context, v *models.Virtual) error { + return c.post(ctx, "/api/v2/virtuals", v, v) +} + +func (c *Client) UpdateVirtual(ctx context.Context, v *models.Virtual) error { + return c.put(ctx, fmt.Sprintf("/api/v2/virtuals/%s", v.Name), v, v) +} + +func (c *Client) DeleteVirtual(ctx context.Context, name string) error { + return c.delete(ctx, fmt.Sprintf("/api/v2/virtuals/%s", name)) +} diff --git a/pkg/models/artifact.go b/pkg/models/artifact.go new file mode 100644 index 0000000..f65dfea --- /dev/null +++ b/pkg/models/artifact.go @@ -0,0 +1,38 @@ +package models + +import "time" + +type Blob struct { + ContentHash string `json:"content_hash"` + S3Key string `json:"s3_key"` + SizeBytes int64 `json:"size_bytes"` + ContentType string `json:"content_type"` + CreatedAt time.Time `json:"created_at"` +} + +type Artifact struct { + ID int64 `json:"id"` + RemoteName string `json:"remote_name"` + Path string `json:"path"` + ContentHash string `json:"content_hash"` + UpstreamETag string `json:"upstream_etag,omitempty"` + UpstreamLastModified *time.Time `json:"upstream_last_modified,omitempty"` + FirstSeenAt time.Time `json:"first_seen_at"` + LastFetchedAt time.Time `json:"last_fetched_at"` + LastAccessedAt time.Time `json:"last_accessed_at"` + FetchCount int64 `json:"fetch_count"` + AccessCount int64 `json:"access_count"` + SizeBytes int64 `json:"size_bytes"` + ContentType string `json:"content_type,omitempty"` +} + +type AccessLogEntry struct { + ID int64 `json:"id"` + RemoteName string `json:"remote_name"` + Path string `json:"path"` + CacheHit bool `json:"cache_hit"` + SizeBytes int64 `json:"size_bytes"` + UpstreamMS int `json:"upstream_ms"` + ClientIP string `json:"client_ip"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/pkg/models/local.go b/pkg/models/local.go new file mode 100644 index 0000000..f2ddd76 --- /dev/null +++ b/pkg/models/local.go @@ -0,0 +1,11 @@ +package models + +import "time" + +type LocalFile struct { + ID int64 `json:"id"` + RepoName string `json:"repo_name"` + FilePath string `json:"file_path"` + ContentHash string `json:"content_hash"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/pkg/models/package_type.go b/pkg/models/package_type.go new file mode 100644 index 0000000..ea316a8 --- /dev/null +++ b/pkg/models/package_type.go @@ -0,0 +1,47 @@ +package models + +import "fmt" + +type PackageType string + +const ( + PackageGeneric PackageType = "generic" + PackageDocker PackageType = "docker" + PackageHelm PackageType = "helm" + PackagePyPI PackageType = "pypi" + PackageNPM PackageType = "npm" + PackageRPM PackageType = "rpm" + PackageAlpine PackageType = "alpine" + PackagePuppet PackageType = "puppet" + PackageTerraform PackageType = "terraform" + PackageGoProxy PackageType = "goproxy" +) + +var validPackageTypes = map[PackageType]bool{ + PackageGeneric: true, + PackageDocker: true, + PackageHelm: true, + PackagePyPI: true, + PackageNPM: true, + PackageRPM: true, + PackageAlpine: true, + PackagePuppet: true, + PackageTerraform: true, + PackageGoProxy: true, +} + +func (p PackageType) Valid() bool { + return validPackageTypes[p] +} + +func (p PackageType) String() string { + return string(p) +} + +func ParsePackageType(s string) (PackageType, error) { + pt := PackageType(s) + if !pt.Valid() { + return "", fmt.Errorf("unknown package type: %q", s) + } + return pt, nil +} diff --git a/pkg/models/package_type_test.go b/pkg/models/package_type_test.go new file mode 100644 index 0000000..f842dca --- /dev/null +++ b/pkg/models/package_type_test.go @@ -0,0 +1,58 @@ +package models_test + +import ( + "testing" + + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +func TestPackageTypeValid(t *testing.T) { + valid := []models.PackageType{ + models.PackageGeneric, + models.PackageDocker, + models.PackageHelm, + models.PackagePyPI, + models.PackageNPM, + models.PackageRPM, + models.PackageAlpine, + models.PackagePuppet, + models.PackageTerraform, + models.PackageGoProxy, + } + for _, pt := range valid { + if !pt.Valid() { + t.Errorf("expected %q to be valid", pt) + } + } +} + +func TestPackageTypeInvalid(t *testing.T) { + invalid := []string{"", "bogus", "Docker", "HELM"} + for _, s := range invalid { + pt := models.PackageType(s) + if pt.Valid() { + t.Errorf("expected %q to be invalid", s) + } + } +} + +func TestParsePackageType(t *testing.T) { + pt, err := models.ParsePackageType("docker") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pt != models.PackageDocker { + t.Errorf("expected docker, got %q", pt) + } + + _, err = models.ParsePackageType("nope") + if err == nil { + t.Fatal("expected error for unknown type") + } +} + +func TestPackageTypeString(t *testing.T) { + if models.PackageGoProxy.String() != "goproxy" { + t.Errorf("expected 'goproxy', got %q", models.PackageGoProxy.String()) + } +} diff --git a/pkg/models/remote.go b/pkg/models/remote.go new file mode 100644 index 0000000..a8eeebc --- /dev/null +++ b/pkg/models/remote.go @@ -0,0 +1,40 @@ +package models + +import "time" + +type Remote struct { + Name string `json:"name"` + PackageType PackageType `json:"package_type"` + BaseURL string `json:"base_url"` + Description string `json:"description,omitempty"` + Username string `json:"-"` + Password string `json:"-"` + + ImmutableTTL int `json:"immutable_ttl"` + MutableTTL int `json:"mutable_ttl"` + CheckMutable bool `json:"check_mutable"` + + Patterns []string `json:"patterns,omitempty"` + Blocklist []string `json:"blocklist,omitempty"` + MutablePatterns []string `json:"mutable_patterns,omitempty"` + ImmutablePatterns []string `json:"immutable_patterns,omitempty"` + + BanTagsEnabled bool `json:"ban_tags_enabled,omitempty"` + BanTags []string `json:"ban_tags,omitempty"` + + QuarantineEnabled bool `json:"quarantine_enabled,omitempty"` + QuarantineDays int `json:"quarantine_days,omitempty"` + + StaleOnError bool `json:"stale_on_error"` + + ReleasesRemote string `json:"releases_remote,omitempty"` + ManagedBy string `json:"managed_by,omitempty"` + + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type RemoteWithStats struct { + Remote + Stats RemoteStats `json:"stats"` +} diff --git a/pkg/models/stats.go b/pkg/models/stats.go new file mode 100644 index 0000000..fff4799 --- /dev/null +++ b/pkg/models/stats.go @@ -0,0 +1,23 @@ +package models + +type RemoteStats struct { + ObjectCount int64 `json:"object_count"` + TotalBytes int64 `json:"total_bytes"` + HitRate30d float64 `json:"hit_rate_30d"` + Requests30d int64 `json:"requests_30d"` + BandwidthSaved int64 `json:"bandwidth_saved_30d"` +} + +type OverviewStats struct { + TotalRemotes int `json:"total_remotes"` + TotalObjects int64 `json:"total_objects"` + TotalBytes int64 `json:"total_bytes"` + TotalBlobsDeduped int64 `json:"total_blobs_deduped"` + BandwidthSaved30d int64 `json:"bandwidth_saved_30d"` +} + +type RemoteHealth struct { + Status string `json:"status"` // healthy, degraded, down + LastError string `json:"last_error,omitempty"` + ConsecutiveFailures int `json:"consecutive_failures"` +} diff --git a/pkg/models/virtual.go b/pkg/models/virtual.go new file mode 100644 index 0000000..c7be7b2 --- /dev/null +++ b/pkg/models/virtual.go @@ -0,0 +1,13 @@ +package models + +import "time" + +type Virtual struct { + Name string `json:"name"` + PackageType PackageType `json:"package_type"` + Description string `json:"description,omitempty"` + Members []string `json:"members"` + ManagedBy string `json:"managed_by,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index 8be2f2d..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,60 +0,0 @@ -[project] -name = "artifactapi" -dynamic = ["version"] -description = "Generic artifact caching system with support for various package managers" - -dependencies = [ - "fastapi>=0.104.0", - "uvicorn[standard]>=0.24.0", - "httpx>=0.25.0", - "redis>=5.0.0", - "boto3>=1.29.0", - "psycopg2-binary>=2.9.0", - "pyyaml>=6.0", - "lxml>=4.9.0", - "prometheus-client>=0.19.0", - "python-multipart>=0.0.6", - "msgpack>=1.0.0", -] -requires-python = ">=3.11" -readme = "README.md" -license = {text = "MIT"} - -[project.scripts] -artifactapi = "artifactapi.main:main" - -[build-system] -requires = ["hatchling", "hatch-vcs"] -build-backend = "hatchling.build" - -[tool.hatch.version] -source = "vcs" - -[tool.hatch.metadata] -allow-direct-references = true - -[tool.hatch.build.targets.wheel] -packages = ["src/artifactapi"] - -[project.optional-dependencies] -dev = [ - "pytest>=7.4.0", - "pytest-asyncio>=0.21.0", - "black>=23.9.0", - "isort>=5.12.0", - "mypy>=1.6.0", - "ruff>=0.4.0", - "tox>=4.0.0", - "pre-commit>=3.0.0", -] - -[tool.pytest.ini_options] -asyncio_mode = "auto" -testpaths = ["tests"] - -[tool.ruff] -line-length = 140 - -[tool.ruff.lint] -select = ["E", "F", "I", "UP"] -ignore = ["E501"] diff --git a/src/artifactapi/__init__.py b/src/artifactapi/__init__.py deleted file mode 100644 index 551d7c3..0000000 --- a/src/artifactapi/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Artifact API package diff --git a/src/artifactapi/artifact/__init__.py b/src/artifactapi/artifact/__init__.py deleted file mode 100644 index 9a52d4e..0000000 --- a/src/artifactapi/artifact/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from . import discovery, docker, flush, local, proxy - -__all__ = ["discovery", "docker", "flush", "local", "proxy"] diff --git a/src/artifactapi/artifact/discovery.py b/src/artifactapi/artifact/discovery.py deleted file mode 100644 index 786ccb5..0000000 --- a/src/artifactapi/artifact/discovery.py +++ /dev/null @@ -1,82 +0,0 @@ -import logging -import re -from typing import Any -from urllib.parse import urlparse - -import httpx -from fastapi import HTTPException - -from .proxy import cache_single_artifact - -logger = logging.getLogger(__name__) - - -async def _discover_github_releases(remote: str, include_pattern: str) -> list[str]: - match = re.match(r"github\.com/([^/]+)/([^/]+)", remote) - if not match: - raise HTTPException(status_code=400, detail="Invalid GitHub remote format") - - owner, repo = match.groups() - - async with httpx.AsyncClient(follow_redirects=True) as client: - response = await client.get(f"https://api.github.com/repos/{owner}/{repo}/releases") - if response.status_code != 200: - raise HTTPException(status_code=response.status_code, detail=f"Failed to fetch releases: {response.text}") - - releases = response.json() - regex = re.compile(include_pattern.replace("*", ".*")) - return [ - asset["browser_download_url"] - for release in releases - for asset in release.get("assets", []) - if regex.search(asset["browser_download_url"]) - ] - - -async def _discover(remote: str, include_pattern: str) -> list[str]: - if "github.com" in remote: - return await _discover_github_releases(remote, include_pattern) - raise HTTPException(status_code=400, detail=f"Unsupported remote: {remote}") - - -async def cache_artifacts(remote: str, include_pattern: str, storage) -> dict[str, Any]: - try: - matching_urls = await _discover(remote, include_pattern) - - if not matching_urls: - return {"message": "No matching artifacts found", "cached_count": 0, "artifacts": []} - - cached_artifacts = [] - for url in matching_urls: - result = await cache_single_artifact(url, "", "", storage, {}) - cached_artifacts.append(result) - - cached_count = sum(1 for a in cached_artifacts if a["status"] in ["cached", "already_cached"]) - return { - "message": f"Processed {len(matching_urls)} artifacts, {cached_count} successfully cached", - "cached_count": cached_count, - "artifacts": cached_artifacts, - } - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -async def list_artifacts(remote: str, include_pattern: str, storage) -> dict[str, Any]: - try: - matching_urls = await _discover(remote, include_pattern) - cached_artifacts = [] - for url in matching_urls: - parsed = urlparse(url) - key = storage.get_object_key(remote, parsed.path) - if storage.exists(key): - cached_artifacts.append({"url": url, "cached_url": storage.get_url(key), "key": key}) - - return { - "remote": remote, - "pattern": include_pattern, - "total_found": len(matching_urls), - "cached_count": len(cached_artifacts), - "artifacts": cached_artifacts, - } - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) diff --git a/src/artifactapi/artifact/docker.py b/src/artifactapi/artifact/docker.py deleted file mode 100644 index 79e4eee..0000000 --- a/src/artifactapi/artifact/docker.py +++ /dev/null @@ -1,138 +0,0 @@ -import asyncio -import hashlib -import json -import logging -import re - -from fastapi import HTTPException, Request, Response - -from . import proxy as _proxy - -logger = logging.getLogger(__name__) - - -def ping() -> Response: - return Response( - content="{}", - media_type="application/json", - headers={"Docker-Distribution-Api-Version": "registry/2.0"}, - ) - - -async def proxy(request: Request, remote_name: str, path: str, storage, cache, config, metrics) -> Response: - remote_config = config.get_remote_config(remote_name) - if not remote_config: - raise HTTPException(status_code=404, detail=f"Remote '{remote_name}' not configured") - if remote_config.get("package") != "docker": - raise HTTPException(status_code=400, detail=f"Remote '{remote_name}' is not a docker remote") - - patterns = config.get_immutable_patterns(remote_name, "") - if patterns: - path_parts = path.split("/") - image_name = "/".join(path_parts[:2]) if len(path_parts) >= 2 else path - if not any(re.search(p, path) or re.search(p, image_name) for p in patterns): - logger.info(f"PATTERN BLOCKED: {remote_name}/{path}") - raise HTTPException(status_code=403, detail="Image not allowed by configuration patterns") - - if remote_config.get("ban_tags_enabled", False): - ban_tags = remote_config.get("ban_tags", []) - if ban_tags: - tag_match = re.search(r"/manifests/([^/]+)$", path) - if tag_match: - tag = tag_match.group(1) - if not tag.startswith("sha256:") and tag in ban_tags: - logger.info(f"TAG BANNED: {remote_name}/{path} (tag: {tag})") - raise HTTPException(status_code=403, detail=f"Tag '{tag}' is not permitted on this remote") - - base_url = remote_config.get("base_url", "").rstrip("/") - remote_url = f"{base_url}/v2/{path}" - - cached_key = storage.get_object_key(remote_name, path) - if not storage.exists(cached_key): - cached_key = None - - is_mutable = cache.is_mutable_file(path, config.get_mutable_patterns(remote_name)) - - if cached_key and is_mutable: - if not cache.is_index_valid(remote_name, path): - if not await _proxy.handle_expired_mutable(remote_name, path, remote_url, config, cache, storage): - cached_key = None - - lock_acquired = False - if not cached_key: - lock_acquired = cache.acquire_fetch_lock(remote_name, path) - if not lock_acquired: - # Another pod is already fetching — poll storage briefly before issuing a duplicate upstream request - for _ in range(10): - await asyncio.sleep(0.5) - probe_key = storage.get_object_key(remote_name, path) - if storage.exists(probe_key): - cached_key = probe_key - break - - if not cached_key: - logger.info(f"Cache MISS: {remote_name}/{path} - fetching from remote: {remote_url}") - try: - result = await _proxy.cache_single_artifact(remote_url, remote_name, path, storage, remote_config) - if result["status"] == "error": - raise HTTPException(status_code=502, detail=f"Failed to fetch: {result['error']}") - if result["status"] == "cached" and is_mutable: - cache_config = config.get_cache_config(remote_name) - mutable_ttl = cache_config.get("mutable_ttl", 3600) - cache.mark_index_cached(remote_name, path, mutable_ttl) - logger.info(f"Mutable file cached with TTL: {remote_name}/{path} (ttl: {mutable_ttl}s)") - if result.get("etag") or result.get("last_modified"): - cache.store_mutable_meta(remote_name, path, result.get("etag"), result.get("last_modified")) - if not is_mutable: - published = result.get("last_modified") - if published: - cache.store_artifact_published(remote_name, path, published) - _proxy._check_quarantine(remote_name, published, config) - finally: - if lock_acquired: - cache.release_fetch_lock(remote_name, path) - elif not is_mutable: - published = cache.get_artifact_published(remote_name, path) - if not published: - published = await _proxy._fetch_last_modified(remote_url, remote_config) - if published: - cache.store_artifact_published(remote_name, path, published) - _proxy._check_quarantine(remote_name, published, config) - - artifact_data = storage.download_object(storage.get_object_key(remote_name, path)) - - is_blob = "/blobs/" in path - if is_blob: - content_type = "application/octet-stream" - else: - try: - manifest_json = json.loads(artifact_data) - content_type = manifest_json.get("mediaType") - if not content_type: - if "manifests" in manifest_json: - content_type = "application/vnd.oci.image.index.v1+json" - else: - content_type = "application/vnd.oci.image.manifest.v1+json" - except Exception: - content_type = "application/vnd.oci.image.manifest.v1+json" - - digest = f"sha256:{hashlib.sha256(artifact_data).hexdigest()}" - - # Cross-link tag manifests to their sha256 digest key so digest-addressed pulls hit cache - if is_mutable and "/manifests/" in path: - digest_path = re.sub(r"/manifests/[^/]+$", f"/manifests/{digest}", path) - digest_key = storage.get_object_key(remote_name, digest_path) - if not storage.exists(digest_key): - storage.upload(digest_key, artifact_data) - - headers = { - "Docker-Distribution-Api-Version": "registry/2.0", - "Docker-Content-Digest": digest, - "Content-Length": str(len(artifact_data)), - } - - if request.method == "HEAD": - return Response(status_code=200, headers=headers, media_type=content_type) - - metrics.record_cache_hit(remote_name, len(artifact_data)) - return Response(content=artifact_data, media_type=content_type, headers=headers) diff --git a/src/artifactapi/artifact/flush.py b/src/artifactapi/artifact/flush.py deleted file mode 100644 index c446066..0000000 --- a/src/artifactapi/artifact/flush.py +++ /dev/null @@ -1,66 +0,0 @@ -import logging - -from fastapi import HTTPException - -logger = logging.getLogger(__name__) - - -def handle(remote: str | None, cache_type: str, cache, storage) -> dict: - try: - result = {"remote": remote, "cache_type": cache_type, "flushed": {"redis_keys": 0, "s3_objects": 0, "operations": []}} - - if cache_type in ["all", "index", "metrics"] and cache.available and cache.client: - patterns = [] - - if cache_type in ["all", "index"]: - if remote: - patterns += [f"index:{remote}:*", f"mutable:meta:{remote}:*"] - else: - patterns += ["index:*", "mutable:meta:*"] - - if cache_type in ["all", "metrics"]: - patterns.append(f"metrics:*:{remote}" if remote else "metrics:*") - - for pattern in patterns: - keys = cache.client.keys(pattern) - if keys: - cache.client.delete(*keys) - result["flushed"]["redis_keys"] += len(keys) - logger.info(f"Cache flush: deleted {len(keys)} Redis keys matching '{pattern}'") - - if result["flushed"]["redis_keys"] > 0: - result["flushed"]["operations"].append(f"Deleted {result['flushed']['redis_keys']} Redis keys") - - if cache_type in ["all", "files"]: - try: - list_params = {"Bucket": storage.bucket} - if remote: - list_params["Prefix"] = f"{remote}/" - - response = storage.client.list_objects_v2(**list_params) - if "Contents" in response: - objects_to_delete = [obj["Key"] for obj in response["Contents"]] - for key in objects_to_delete: - try: - storage.client.delete_object(Bucket=storage.bucket, Key=key) - result["flushed"]["s3_objects"] += 1 - except Exception as e: - logger.warning(f"Failed to delete S3 object {key}: {e}") - - if objects_to_delete: - scope = f" for remote '{remote}'" if remote else "" - result["flushed"]["operations"].append(f"Deleted {len(objects_to_delete)} S3 objects{scope}") - logger.info(f"Cache flush: deleted {len(objects_to_delete)} S3 objects{scope}") - - except Exception as e: - result["flushed"]["operations"].append(f"S3 flush failed: {str(e)}") - logger.error(f"Cache flush S3 error: {e}") - - if not result["flushed"]["operations"]: - result["flushed"]["operations"].append("No cache entries found to flush") - - return result - - except Exception as e: - logger.error(f"Cache flush error: {e}") - raise HTTPException(status_code=500, detail=f"Cache flush failed: {str(e)}") diff --git a/src/artifactapi/artifact/local.py b/src/artifactapi/artifact/local.py deleted file mode 100644 index ab978e8..0000000 --- a/src/artifactapi/artifact/local.py +++ /dev/null @@ -1,113 +0,0 @@ -import hashlib -import logging -import os - -from fastapi import HTTPException, Response, UploadFile -from fastapi.responses import JSONResponse - -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: - if not config.get_local_config(remote_name): - raise HTTPException(status_code=404, detail=f"Local repository '{remote_name}' not configured") - - try: - content = await file.read() - sha256_sum = hashlib.sha256(content).hexdigest() - - if database.file_exists(remote_name, path): - raise HTTPException(status_code=409, detail="File already exists") - - s3_key = f"local/{remote_name}/{path}" - content_type = file.content_type or "application/octet-stream" - - try: - storage.upload(s3_key, content) - except Exception as e: - raise HTTPException(status_code=500, detail=f"Upload failed: {e}") - - success = database.add_local_file( - repository_name=remote_name, - file_path=path, - s3_key=s3_key, - size_bytes=len(content), - sha256_sum=sha256_sum, - content_type=content_type, - ) - - if not success: - storage.delete_object(s3_key) - raise HTTPException(status_code=500, detail="Failed to save file metadata") - - return JSONResponse( - { - "message": "File uploaded successfully", - "file_path": path, - "size_bytes": len(content), - "sha256_sum": sha256_sum, - "content_type": content_type, - } - ) - - except HTTPException: - raise - except Exception as e: - raise HTTPException(status_code=500, detail=f"Upload failed: {str(e)}") - - -def check_exists(remote_name: str, path: str, database, config) -> Response: - if not config.get_local_config(remote_name): - raise HTTPException(status_code=404, detail=f"Local repository '{remote_name}' not configured") - - try: - metadata = database.get_local_file_metadata(remote_name, path) - if not metadata: - raise HTTPException(status_code=404, detail="File not found") - - return Response( - headers={ - "Content-Length": str(metadata["size_bytes"]), - "Content-Type": metadata.get("content_type", "application/octet-stream"), - "X-SHA256": metadata["sha256_sum"], - "X-Created-At": metadata["created_at"].isoformat() if metadata["created_at"] else "", - "X-Uploaded-At": metadata["uploaded_at"].isoformat() if metadata["uploaded_at"] else "", - } - ) - except HTTPException: - raise - except Exception as e: - raise HTTPException(status_code=500, detail=f"Check failed: {str(e)}") - - -def delete(remote_name: str, path: str, storage, database, config) -> JSONResponse: - if not config.get_local_config(remote_name): - raise HTTPException(status_code=404, detail=f"Local repository '{remote_name}' not configured") - - try: - s3_key = database.delete_local_file(remote_name, path) - if not s3_key: - raise HTTPException(status_code=404, detail="File not found") - - if not storage.delete_object(s3_key): - logger.warning(f"Failed to delete S3 object {s3_key} after database removal") - - return JSONResponse({"message": "File deleted successfully"}) - except HTTPException: - raise - except Exception as e: - raise HTTPException(status_code=500, detail=f"Delete failed: {str(e)}") diff --git a/src/artifactapi/artifact/proxy.py b/src/artifactapi/artifact/proxy.py deleted file mode 100644 index 8d3facd..0000000 --- a/src/artifactapi/artifact/proxy.py +++ /dev/null @@ -1,327 +0,0 @@ -import base64 -import logging -import os -import re -from datetime import UTC, datetime, timedelta -from email.utils import parsedate_to_datetime - -import httpx -from fastapi import HTTPException, Request, Response - -from ..auth import get_docker_token_for_response -from ..remote import helm as _helm -from ..remote import npm as _npm -from ..remote import puppet as _puppet -from ..remote import python as _pypi -from ..remote import terraform as _terraform -from ..remote.base import get_content_type - -logger = logging.getLogger(__name__) - - -class UpstreamUnreachable(Exception): - """Raised when the upstream backend cannot be contacted (network or timeout error).""" - - -def _check_quarantine(remote_name: str, last_modified_str: str | None, config) -> None: - """Raise HTTP 404 if the artifact is within the per-remote quarantine window. - - Fails open (allows the request) when the publish date cannot be determined. - """ - enabled, days = config.get_quarantine_config(remote_name) - if not enabled or not days: - return - if not last_modified_str: - return # cannot determine age → allow - try: - publish_date = parsedate_to_datetime(last_modified_str) - except Exception: - return # unparseable → allow - cutoff = datetime.now(UTC) - timedelta(days=days) - if publish_date > cutoff: - available_on = (publish_date + timedelta(days=days)).date() - raise HTTPException( - status_code=404, - detail=( - f"Package quarantined: published {publish_date.date()}, available after {available_on} ({days}-day new-release quarantine)" - ), - ) - - -async def _fetch_last_modified(remote_url: str, remote_cfg: dict) -> str | None: - """HEAD the upstream URL and return the Last-Modified header, or None on any failure.""" - auth = _basic_auth_header(remote_cfg) - try: - async with httpx.AsyncClient(follow_redirects=True) as client: - response = await client.head(remote_url, headers=auth, timeout=10.0) - return response.headers.get("Last-Modified") - except Exception: - return None - - -def _basic_auth_header(remote_cfg: dict) -> dict[str, str]: - username = remote_cfg.get("username") - password = remote_cfg.get("password") - if username and password: - token = base64.b64encode(f"{username}:{password}".encode()).decode() - return {"Authorization": f"Basic {token}"} - return {} - - -def _resolve_content( - data: bytes, - path: str, - filename: str, - remote_config: dict, - request: Request, - remote_name: str = "", -) -> tuple[bytes, str]: - package = remote_config.get("package") - proxy_base = str(request.base_url).rstrip("/") - base_url = remote_config.get("base_url", "").rstrip("/") - - if package == "pypi": - return _pypi.resolve_content(data, path, filename, remote_config.get("immutable_patterns", []), base_url, proxy_base, remote_name) - if package == "npm": - return _npm.resolve_content(data, path, filename, remote_config.get("immutable_patterns", []), base_url, proxy_base, remote_name) - if package == "helm": - return _helm.resolve_content(data, path, filename, base_url, proxy_base, remote_name) - if package == "puppet": - return _puppet.resolve_content(data, path, filename, base_url, proxy_base, remote_name) - if package == "terraform": - releases_remote = remote_config.get("releases_remote") - return _terraform.resolve_content(data, path, filename, base_url, proxy_base, remote_name, releases_remote) - return data, get_content_type(filename) - - -def construct_url(remote_config: dict, path: str) -> str: - base_url = remote_config.get("base_url", "").rstrip("/") - if remote_config.get("package") == "docker": - return f"{base_url}/v2/{path}" - if remote_config.get("package") == "pypi": - return _pypi.construct_url(base_url, path) - if remote_config.get("package") == "terraform": - return _terraform.construct_url(base_url, path) - return f"{base_url}/{path}" - - -async def cache_single_artifact(url: str, remote_name: str, path: str, storage, remote_config: dict) -> dict: - key = storage.get_object_key(remote_name, path) - - if storage.exists(key): - logger.info(f"Cache ALREADY EXISTS: {url} (key: {key})") - return {"url": url, "cached_url": storage.get_url(key), "status": "already_cached"} - - try: - is_docker = remote_config.get("package") == "docker" or "/v2/" in url - headers = {} - username = remote_config.get("username") - password = remote_config.get("password") - - if is_docker: - if "/manifests/" in url: - headers["Accept"] = ( - "application/vnd.docker.distribution.manifest.v2+json," - "application/vnd.oci.image.manifest.v1+json," - "application/vnd.oci.image.index.v1+json," - "application/vnd.docker.distribution.manifest.list.v2+json" - ) - elif "/blobs/" in url: - headers["Accept"] = "application/octet-stream" - elif username and password: - headers["Authorization"] = "Basic " + base64.b64encode(f"{username}:{password}".encode()).decode() - - async with httpx.AsyncClient(follow_redirects=True) as client: - response = await client.get(url, headers=headers) - - if response.status_code == 401 and is_docker: - www_auth = response.headers.get("WWW-Authenticate", "") - token = await get_docker_token_for_response(www_auth, username, password) - if token: - headers["Authorization"] = f"Bearer {token}" - response = await client.get(url, headers=headers) - - response.raise_for_status() - storage.upload(key, response.content) - logger.info(f"Cache ADD SUCCESS: {url} (size: {len(response.content)} bytes, key: {key})") - - return { - "url": url, - "cached_url": storage.get_url(key), - "storage_path": f"s3://{storage.bucket}/{key}", - "size": len(response.content), - "status": "cached", - "etag": response.headers.get("ETag"), - "last_modified": response.headers.get("Last-Modified"), - } - - except Exception as e: - return {"url": url, "status": "error", "error": str(e)} - - -async def _upstream_reachable(url: str, auth_headers: dict | None = None) -> bool: - try: - async with httpx.AsyncClient(follow_redirects=True) as client: - await client.head(url, headers=auth_headers or {}, timeout=10.0) - return True - except (httpx.NetworkError, httpx.TimeoutException): - return False - except Exception: - return True - - -async def check_upstream_changed(remote_url: str, remote_name: str, path: str, cache, auth_headers: dict | None = None) -> bool: - meta = cache.get_mutable_meta(remote_name, path) - if not meta: - return True - - headers = dict(auth_headers or {}) - if meta.get("etag"): - headers["If-None-Match"] = meta["etag"] - if meta.get("last_modified"): - headers["If-Modified-Since"] = meta["last_modified"] - if not (meta.get("etag") or meta.get("last_modified")): - return True - - try: - async with httpx.AsyncClient(follow_redirects=True) as client: - response = await client.head(remote_url, headers=headers) - return response.status_code != 304 - except (httpx.NetworkError, httpx.TimeoutException) as exc: - raise UpstreamUnreachable(str(exc)) from exc - - -async def handle_expired_mutable(remote_name: str, path: str, remote_url: str, config, cache, storage) -> bool: - """Handle an expired mutable file. Returns True if the cached copy is still valid.""" - mutable_ttl = config.get_cache_config(remote_name).get("mutable_ttl", 3600) - remote_cfg = config.get_remote_config(remote_name) or {} - auth = _basic_auth_header(remote_cfg) - check_updates = remote_cfg.get("check_mutable_updates", False) - user_mutable = check_updates and cache.is_mutable_file(path, config.get_user_mutable_patterns(remote_name)) - - if user_mutable: - try: - changed = await check_upstream_changed(remote_url, remote_name, path, cache, auth) - except UpstreamUnreachable: - cache.mark_index_cached(remote_name, path, mutable_ttl) - logger.warning(f"Mutable STALE (backend unreachable): {remote_name}/{path} - TTL extended ({mutable_ttl}s)") - return True - if not changed: - cache.mark_index_cached(remote_name, path, mutable_ttl) - logger.info(f"Mutable file UNCHANGED: {remote_name}/{path} - TTL refreshed ({mutable_ttl}s)") - return True - logger.info(f"Mutable file CHANGED: {remote_name}/{path} - re-downloading") - else: - if not await _upstream_reachable(remote_url, auth): - cache.mark_index_cached(remote_name, path, mutable_ttl) - logger.warning(f"Mutable STALE (backend unreachable): {remote_name}/{path} - TTL extended ({mutable_ttl}s)") - return True - logger.info(f"Mutable file EXPIRED: {remote_name}/{path} - removing from cache") - - cache.cleanup_expired_index(storage, remote_name, path) - return False - - -async def handle(request: Request, remote_name: str, path: str, storage, cache, config, database, metrics) -> Response: - remote_config = config.get_remote_config(remote_name) - if not remote_config: - raise HTTPException(status_code=404, detail=f"Remote '{remote_name}' not configured") - - path_parts = path.split("/") - if len(path_parts) >= 2: - repo_path = f"{path_parts[0]}/{path_parts[1]}" - file_path = "/".join(path_parts[2:]) - else: - repo_path = path - file_path = path - - mutable_patterns = config.get_mutable_patterns(remote_name) - if not cache.is_mutable_file(file_path, mutable_patterns) and not cache.is_mutable_file(path, mutable_patterns): - patterns = config.get_immutable_patterns(remote_name, repo_path) - if patterns and not any(re.search(p, file_path) or re.search(p, path) for p in patterns): - logger.info(f"PATTERN BLOCKED: {remote_name}/{path} - not matching include patterns") - raise HTTPException(status_code=403, detail="Artifact not allowed by configuration patterns") - - remote_url = construct_url(remote_config, path) - if not remote_config.get("base_url"): - raise HTTPException(status_code=500, detail=f"No base_url configured for remote '{remote_name}'") - - cached_key = storage.get_object_key(remote_name, path) - if not storage.exists(cached_key): - cached_key = None - - filename = os.path.basename(path) - is_mutable = cache.is_mutable_file(path, mutable_patterns) - - if cached_key and is_mutable: - if not cache.is_index_valid(remote_name, path): - if not await handle_expired_mutable(remote_name, path, remote_url, config, cache, storage): - cached_key = None - - if cached_key: - if not is_mutable: - published = cache.get_artifact_published(remote_name, path) - if not published: - published = await _fetch_last_modified(remote_url, remote_config) - if published: - cache.store_artifact_published(remote_name, path, published) - _check_quarantine(remote_name, published, config) - - try: - artifact_data = storage.download_object(cached_key) - artifact_data, content_type = _resolve_content(artifact_data, path, filename, remote_config, request, remote_name) - logger.info(f"Cache HIT: {remote_name}/{path} (size: {len(artifact_data)} bytes, key: {cached_key})") - metrics.record_cache_hit(remote_name, len(artifact_data)) - database.record_artifact_mapping(cached_key, remote_name, path, len(artifact_data)) - return Response( - content=artifact_data, - media_type=content_type, - headers={ - "Content-Disposition": f"attachment; filename={filename}", - "X-Artifact-Source": "cache", - "X-Artifact-Size": str(len(artifact_data)), - }, - ) - except HTTPException: - raise - except Exception as e: - raise HTTPException(status_code=500, detail=f"Error retrieving cached artifact: {str(e)}") - - logger.info(f"Cache MISS: {remote_name}/{path} - fetching from remote: {remote_url}") - result = await cache_single_artifact(remote_url, remote_name, path, storage, remote_config) - - if result["status"] == "error": - logger.error(f"Cache ADD FAILED: {remote_name}/{path} - {result['error']}") - raise HTTPException(status_code=502, detail=f"Failed to fetch artifact: {result['error']}") - - if result["status"] == "cached" and is_mutable: - cache_config = config.get_cache_config(remote_name) - mutable_ttl = cache_config.get("mutable_ttl", 3600) - cache.mark_index_cached(remote_name, path, mutable_ttl) - logger.info(f"Mutable file cached with TTL: {remote_name}/{path} (ttl: {mutable_ttl}s)") - if result.get("etag") or result.get("last_modified"): - cache.store_mutable_meta(remote_name, path, result.get("etag"), result.get("last_modified")) - - if not is_mutable: - published = result.get("last_modified") - if published: - cache.store_artifact_published(remote_name, path, published) - _check_quarantine(remote_name, published, config) - - try: - cache_key = storage.get_object_key(remote_name, path) - artifact_data = storage.download_object(cache_key) - artifact_data, content_type = _resolve_content(artifact_data, path, filename, remote_config, request, remote_name) - metrics.record_cache_miss(remote_name, len(artifact_data)) - database.record_artifact_mapping(cache_key, remote_name, path, len(artifact_data)) - return Response( - content=artifact_data, - media_type=content_type, - headers={ - "Content-Disposition": f"attachment; filename={filename}", - "X-Artifact-Source": "remote", - "X-Artifact-Size": str(len(artifact_data)), - }, - ) - except Exception as e: - raise HTTPException(status_code=500, detail=f"Error serving artifact: {str(e)}") diff --git a/src/artifactapi/artifact/virtual.py b/src/artifactapi/artifact/virtual.py deleted file mode 100644 index 3f4c88e..0000000 --- a/src/artifactapi/artifact/virtual.py +++ /dev/null @@ -1,317 +0,0 @@ -import asyncio -import base64 -import logging -import time -from datetime import UTC, date, datetime -from typing import Protocol, runtime_checkable - -import httpx -import msgpack as _msgpack -import yaml -from fastapi import HTTPException, Request, Response - -logger = logging.getLogger(__name__) - -try: - _YamlLoader = yaml.CSafeLoader - _YamlDumperBase = yaml.CDumper -except AttributeError: - _YamlLoader = yaml.SafeLoader - _YamlDumperBase = yaml.Dumper - - -class _HelmDumper(_YamlDumperBase): - """YAML dumper that serializes datetime/date objects back to ISO 8601 strings. - - yaml.safe_load converts timestamp-shaped YAML scalars (e.g. chart `created` - fields) to Python datetime objects. Without a custom representer, yaml.dump - would render them as "2022-12-16 11:08:49+00:00" (space, not T), which - Go's YAML parser cannot unmarshal into time.Time. - """ - - -def _repr_datetime(dumper: yaml.Dumper, data: datetime) -> yaml.ScalarNode: - s = data.strftime("%Y-%m-%dT%H:%M:%S.%f") + ("Z" if data.tzinfo else "") - return dumper.represent_scalar("tag:yaml.org,2002:str", s) - - -def _repr_date(dumper: yaml.Dumper, data: date) -> yaml.ScalarNode: - return dumper.represent_scalar("tag:yaml.org,2002:str", data.isoformat()) - - -_HelmDumper.add_representer(datetime, _repr_datetime) -_HelmDumper.add_representer(date, _repr_date) - - -def _entries_to_msgpack_safe(entries: dict) -> dict: - """Convert datetime/date values to ISO strings for msgpack serialization.""" - result = {} - for chart, versions in entries.items(): - safe_versions = [] - for v in versions: - safe_v = {} - for k, val in v.items(): - if isinstance(val, datetime): - safe_v[k] = val.isoformat() - elif isinstance(val, date): - safe_v[k] = val.isoformat() - else: - safe_v[k] = val - safe_versions.append(safe_v) - result[chart] = safe_versions - return result - - -async def _get_member_index( - member_name: str, - member_cfg: dict, - path: str, - storage, - cache, -) -> tuple[str, dict, int, bytes | None, dict | None]: - """Fetch or retrieve cached index.yaml for one member remote. - - Returns (member_name, member_cfg, ttl, raw_bytes, parsed_entries). - raw_bytes is None if the member is unreachable and not in S3. - parsed_entries is the pre-parsed entries dict (from msgpack cache), or None. - """ - member_ttl = member_cfg.get("cache", {}).get("mutable_ttl", 3600) - s3_key = storage.get_object_key(member_name, path) - msgpack_key = storage.get_object_key(member_name, "index.msgpack") - raw_data: bytes | None = None - parsed_entries: dict | None = None - - if storage.exists(s3_key) and cache.is_index_valid(member_name, path): - try: - raw_data = storage.download_object(s3_key) - logger.info(f"Virtual: cache hit for member '{member_name}'") - except Exception: - raw_data = None - if raw_data is not None and storage.exists(msgpack_key): - try: - packed = storage.download_object(msgpack_key) - parsed_entries = _msgpack.unpackb(packed, raw=False) - logger.debug(f"Virtual: msgpack hit for member '{member_name}'") - except Exception: - parsed_entries = None - - if raw_data is None: - base_url = member_cfg.get("base_url", "").rstrip("/") - upstream_url = f"{base_url}/index.yaml" - headers = {} - username = member_cfg.get("username") - password = member_cfg.get("password") - if username and password: - token = base64.b64encode(f"{username}:{password}".encode()).decode() - headers["Authorization"] = f"Basic {token}" - try: - async with httpx.AsyncClient(follow_redirects=True) as client: - response = await client.get(upstream_url, headers=headers, timeout=30.0) - response.raise_for_status() - raw_data = response.content - except Exception as e: - logger.warning(f"Virtual: failed to fetch index.yaml from member '{member_name}': {e}") - return member_name, member_cfg, member_ttl, None, None - try: - storage.upload(s3_key, raw_data) - cache.mark_index_cached(member_name, path, member_ttl) - except Exception as e: - logger.warning(f"Virtual: failed to cache index.yaml for member '{member_name}': {e}") - - if parsed_entries is None and raw_data is not None: - try: - index = yaml.load(raw_data, Loader=_YamlLoader) - safe_entries = _entries_to_msgpack_safe(index.get("entries") or {}) - storage.upload(msgpack_key, _msgpack.packb(safe_entries, use_bin_type=True)) - parsed_entries = safe_entries - except Exception as e: - logger.warning(f"Virtual: failed to build msgpack cache for '{member_name}': {e}") - - return member_name, member_cfg, member_ttl, raw_data, parsed_entries - - -def _rewrite_urls(urls: list, base_url: str, proxy_base: str, member_name: str) -> list: - proxy_remote = f"{proxy_base}/api/v1/remote/{member_name}" - rewritten = [] - for url in urls: - if url.startswith(("http://", "https://")): - if base_url and url.startswith(base_url): - url = proxy_remote + url[len(base_url) :] - else: - url = f"{proxy_remote}/{url.lstrip('/')}" - rewritten.append(url) - return rewritten - - -def _merge_helm_indexes( - raw_indexes: list[bytes], - parsed_entries_list: list[dict | None], - member_names: list[str], - member_configs: list[dict], - proxy_base: str, -) -> bytes: - """Merge helm index.yaml files with per-member URL rewriting. - - Priority is determined by position in member_names: earlier members win - when the same chart name + version appears in multiple remotes. - Uses pre-parsed msgpack entries when available to skip YAML parsing. - """ - merged_entries: dict[str, list] = {} - - for raw_data, pre_parsed, member_name, member_cfg in zip(raw_indexes, parsed_entries_list, member_names, member_configs): - base_url = member_cfg.get("base_url", "").rstrip("/") - - if pre_parsed is not None: - entries = pre_parsed - else: - try: - index = yaml.load(raw_data, Loader=_YamlLoader) - except Exception as e: - logger.warning(f"Virtual: failed to parse index.yaml from member '{member_name}': {e}") - continue - entries = index.get("entries") or {} - - for chart_name, versions in entries.items(): - for version_entry in versions: - version_entry["urls"] = _rewrite_urls( - version_entry.get("urls") or [], - base_url, - proxy_base, - member_name, - ) - if chart_name not in merged_entries: - merged_entries[chart_name] = list(versions) - else: - existing = {(v.get("name"), v.get("version")) for v in merged_entries[chart_name]} - for version_entry in versions: - key = (version_entry.get("name"), version_entry.get("version")) - if key not in existing: - merged_entries[chart_name].append(version_entry) - existing.add(key) - - merged = { - "apiVersion": "v1", - "entries": merged_entries, - "generated": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S.000Z"), - } - return yaml.dump(merged, Dumper=_HelmDumper, default_flow_style=False, allow_unicode=True).encode() - - -@runtime_checkable -class _VirtualHandler(Protocol): - def accepts_path(self, path: str) -> bool: ... - def merge( - self, - raw_indexes: list[bytes], - parsed_entries: list[dict | None], - member_names: list[str], - member_configs: list[dict], - proxy_base: str, - ) -> bytes: ... - def path_error(self) -> str: ... - - -class _HelmHandler: - def accepts_path(self, path: str) -> bool: - return path == "index.yaml" - - def merge( - self, - raw_indexes: list[bytes], - parsed_entries: list[dict | None], - member_names: list[str], - member_configs: list[dict], - proxy_base: str, - ) -> bytes: - return _merge_helm_indexes(raw_indexes, parsed_entries, member_names, member_configs, proxy_base) - - def path_error(self) -> str: - return "Virtual helm repositories only serve index.yaml; chart tarballs are served directly by member remotes" - - -_HANDLERS: dict[str, _VirtualHandler] = { - "helm": _HelmHandler(), -} - - -async def handle(request: Request, virtual_name: str, path: str, storage, cache, config) -> Response: - virtual_cfg = config.get_virtual_config(virtual_name) - if not virtual_cfg: - raise HTTPException(status_code=404, detail=f"Virtual repository '{virtual_name}' not configured") - - package = virtual_cfg.get("package") - handler = _HANDLERS.get(package) - if handler is None: - raise HTTPException(status_code=400, detail=f"Virtual repositories with package '{package}' are not yet supported") - - if not handler.accepts_path(path): - raise HTTPException(status_code=404, detail=handler.path_error()) - - members = virtual_cfg.get("members", []) - if not members: - raise HTTPException(status_code=500, detail=f"Virtual repository '{virtual_name}' has no members configured") - - virtual_key = storage.get_object_key(virtual_name, path) - - if cache.is_index_valid(virtual_name, path) and storage.exists(virtual_key): - data = storage.download_object(virtual_key) - logger.info(f"Virtual HIT: {virtual_name}/{path}") - return Response(content=data, media_type="text/yaml") - - # Resolve configs first (config reads are sync/cheap) - member_entries = [] - for member_name in members: - member_cfg = config.get_remote_config(member_name) - if not member_cfg: - logger.warning(f"Virtual '{virtual_name}': member '{member_name}' not found in config, skipping") - continue - member_entries.append((member_name, member_cfg)) - - # Fetch all member indexes in parallel; asyncio.gather preserves input order - proxy_base = str(request.base_url).rstrip("/") - t_fetch = time.perf_counter() - results = await asyncio.gather(*[_get_member_index(name, cfg, path, storage, cache) for name, cfg in member_entries]) - fetch_ms = int((time.perf_counter() - t_fetch) * 1000) - - raw_indexes: list[bytes] = [] - used_parsed: list[dict | None] = [] - used_members: list[str] = [] - used_configs: list[dict] = [] - min_ttl: int | None = None - - for member_name, member_cfg, member_ttl, raw_data, parsed_entries in results: - if min_ttl is None or member_ttl < min_ttl: - min_ttl = member_ttl - if raw_data is None: - logger.warning(f"Virtual '{virtual_name}': skipping unreachable member '{member_name}'") - continue - raw_indexes.append(raw_data) - used_parsed.append(parsed_entries) - used_members.append(member_name) - used_configs.append(member_cfg) - - if not raw_indexes: - raise HTTPException(status_code=502, detail=f"Virtual repository '{virtual_name}': no member indices could be fetched") - - if min_ttl is None: - min_ttl = 3600 - - t_merge = time.perf_counter() - merged = await asyncio.to_thread(handler.merge, raw_indexes, used_parsed, used_members, used_configs, proxy_base) - merge_ms = int((time.perf_counter() - t_merge) * 1000) - - try: - t_store = time.perf_counter() - storage.upload(virtual_key, merged) - cache.mark_index_cached(virtual_name, path, min_ttl) - store_ms = int((time.perf_counter() - t_store) * 1000) - msgpack_hits = sum(1 for p in used_parsed if p is not None) - logger.info( - f"Virtual MISS: {virtual_name}/{path} rebuilt from {used_members} " - f"(fetch={fetch_ms}ms merge={merge_ms}ms store={store_ms}ms ttl={min_ttl}s " - f"msgpack={msgpack_hits}/{len(used_members)})" - ) - except Exception as e: - logger.warning(f"Virtual: failed to store merged index for '{virtual_name}': {e}") - - return Response(content=merged, media_type="text/yaml") diff --git a/src/artifactapi/auth/__init__.py b/src/artifactapi/auth/__init__.py deleted file mode 100644 index faffd6e..0000000 --- a/src/artifactapi/auth/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .docker import fetch_token, get_docker_token_for_response, parse_www_authenticate - -__all__ = ["fetch_token", "get_docker_token_for_response", "parse_www_authenticate"] diff --git a/src/artifactapi/auth/docker.py b/src/artifactapi/auth/docker.py deleted file mode 100644 index b781a7f..0000000 --- a/src/artifactapi/auth/docker.py +++ /dev/null @@ -1,96 +0,0 @@ -import logging -import re -import time - -import httpx - -logger = logging.getLogger(__name__) - -# In-memory token cache: key -> (token, expires_at) -_token_cache: dict[str, tuple[str, float]] = {} - -_WWW_AUTH_RE = re.compile( - r'Bearer\s+realm="(?P[^"]+)"' - r'(?:,service="(?P[^"]*)")?' - r'(?:,scope="(?P[^"]*)")?', - re.IGNORECASE, -) - - -def _cache_key(realm: str, service: str, scope: str, username: str | None) -> str: - return f"{realm}|{service}|{scope}|{username or ''}" - - -def _get_cached_token(key: str) -> str | None: - entry = _token_cache.get(key) - if entry and entry[1] > time.time(): - return entry[0] - _token_cache.pop(key, None) - return None - - -def _store_token(key: str, token: str, expires_in: int) -> None: - # Expire 30s early to avoid using a token right as it expires - _token_cache[key] = (token, time.time() + max(expires_in - 30, 10)) - - -async def fetch_token( - realm: str, - service: str, - scope: str, - username: str | None = None, - password: str | None = None, -) -> str | None: - """Fetch a Bearer token from a Docker registry auth server.""" - key = _cache_key(realm, service, scope, username) - cached = _get_cached_token(key) - if cached: - return cached - - params: dict[str, str] = {} - if service: - params["service"] = service - if scope: - params["scope"] = scope - - auth = (username, password) if username and password else None - - try: - async with httpx.AsyncClient(follow_redirects=True) as client: - response = await client.get(realm, params=params, auth=auth) - response.raise_for_status() - data = response.json() - except Exception as e: - logger.warning(f"Docker token fetch failed ({realm}): {e}") - return None - - token = data.get("token") or data.get("access_token") - if not token: - logger.warning(f"Docker token response missing token field: {data}") - return None - - expires_in = int(data.get("expires_in", 300)) - _store_token(key, token, expires_in) - logger.debug(f"Docker token obtained (realm={realm}, service={service}, scope={scope}, expires_in={expires_in}s)") - return token - - -def parse_www_authenticate(header: str) -> tuple[str, str, str] | None: - """Parse WWW-Authenticate: Bearer header. Returns (realm, service, scope) or None.""" - m = _WWW_AUTH_RE.search(header) - if not m: - return None - return m.group("realm"), m.group("service") or "", m.group("scope") or "" - - -async def get_docker_token_for_response( - www_authenticate: str, - username: str | None = None, - password: str | None = None, -) -> str | None: - """Given a WWW-Authenticate header value, fetch and return a Bearer token.""" - parsed = parse_www_authenticate(www_authenticate) - if not parsed: - return None - realm, service, scope = parsed - return await fetch_token(realm, service, scope, username, password) diff --git a/src/artifactapi/cache/__init__.py b/src/artifactapi/cache/__init__.py deleted file mode 100644 index 7f06ae6..0000000 --- a/src/artifactapi/cache/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .redis import RedisCache - -__all__ = ["RedisCache"] diff --git a/src/artifactapi/cache/redis.py b/src/artifactapi/cache/redis.py deleted file mode 100644 index 8c7534b..0000000 --- a/src/artifactapi/cache/redis.py +++ /dev/null @@ -1,143 +0,0 @@ -import hashlib -import re -import time - -import redis - - -class RedisCache: - def __init__(self, redis_url: str): - self.redis_url = redis_url - - try: - self.client = redis.from_url(self.redis_url, decode_responses=True) - self.client.ping() - self.available = True - except Exception as e: - print(f"Redis not available: {e}") - self.client = None - self.available = False - - def is_mutable_file(self, file_path: str, patterns: list[str] | None = None) -> bool: - if patterns is None: - patterns = [] - return any(re.search(p, file_path) for p in patterns) - - def get_index_cache_key(self, remote_name: str, path: str) -> str: - return f"index:{remote_name}:{hashlib.sha256(path.encode()).hexdigest()[:16]}" - - def get_mutable_meta_key(self, remote_name: str, path: str) -> str: - return f"mutable:meta:{remote_name}:{hashlib.sha256(path.encode()).hexdigest()[:16]}" - - def is_index_valid(self, remote_name: str, path: str) -> bool: - if not self.available: - return False - try: - key = self.get_index_cache_key(remote_name, path) - return self.client.exists(key) > 0 - except Exception: - return False - - def mark_index_cached(self, remote_name: str, path: str, ttl: int = 300) -> None: - if not self.available: - return - try: - key = self.get_index_cache_key(remote_name, path) - self.client.setex(key, ttl, str(int(time.time()))) - except Exception: - pass - - def store_mutable_meta(self, remote_name: str, path: str, etag: str | None, last_modified: str | None) -> None: - if not self.available: - return - data = {} - if etag: - data["etag"] = etag - if last_modified: - data["last_modified"] = last_modified - if not data: - return - try: - self.client.hset(self.get_mutable_meta_key(remote_name, path), mapping=data) - except Exception: - pass - - def get_mutable_meta(self, remote_name: str, path: str) -> dict: - if not self.available: - return {} - try: - return self.client.hgetall(self.get_mutable_meta_key(remote_name, path)) or {} - except Exception: - return {} - - def delete_mutable_meta(self, remote_name: str, path: str) -> None: - if not self.available: - return - try: - self.client.delete(self.get_mutable_meta_key(remote_name, path)) - except Exception: - pass - - def get_artifact_published_key(self, remote_name: str, path: str) -> str: - return f"pkg:published:{remote_name}:{hashlib.sha256(path.encode()).hexdigest()[:16]}" - - def store_artifact_published(self, remote_name: str, path: str, last_modified: str) -> None: - """Persist the upstream Last-Modified header for a (typically immutable) artifact.""" - if not self.available: - return - try: - self.client.set(self.get_artifact_published_key(remote_name, path), last_modified) - except Exception: - pass - - def get_artifact_published(self, remote_name: str, path: str) -> str | None: - """Return the stored Last-Modified string for an artifact, or None.""" - if not self.available: - return None - try: - return self.client.get(self.get_artifact_published_key(remote_name, path)) - except Exception: - return None - - def acquire_fetch_lock(self, remote_name: str, path: str, ttl: int = 30) -> bool: - """Try to acquire a short-lived fetch lock. Returns True if acquired, False if held by another caller.""" - if not self.available: - return True # fail open: no Redis → behave as if we always hold the lock - key = f"fetchlock:{remote_name}:{hashlib.sha256(path.encode()).hexdigest()[:16]}" - try: - return bool(self.client.set(key, 1, nx=True, ex=ttl)) - except Exception: - return True - - def release_fetch_lock(self, remote_name: str, path: str) -> None: - if not self.available: - return - key = f"fetchlock:{remote_name}:{hashlib.sha256(path.encode()).hexdigest()[:16]}" - try: - self.client.delete(key) - except Exception: - pass - - def cleanup_expired_index(self, storage, remote_name: str, path: str) -> None: - if not self.available: - return - - try: - import os - - from ..config import ConfigManager - - config_path = os.environ.get("CONFIG_PATH") - if config_path: - config = ConfigManager(config_path) - remote_config = config.get_remote_config(remote_name) - if remote_config: - base_url = remote_config.get("base_url") - if base_url: - s3_key = storage.get_object_key(remote_name, path) - if storage.exists(s3_key): - storage.client.delete_object(Bucket=storage.bucket, Key=s3_key) - except Exception: - pass - - self.delete_mutable_meta(remote_name, path) diff --git a/src/artifactapi/config.py b/src/artifactapi/config.py deleted file mode 100644 index e1d59f1..0000000 --- a/src/artifactapi/config.py +++ /dev/null @@ -1,246 +0,0 @@ -import glob -import json -import os - -import yaml - -_PACKAGE_MUTABLE_PATTERNS: dict[str, list[str]] = { - "alpine": [ - r"APKINDEX\.tar\.gz$", - ], - "rpm": [ - r"repomd\.xml$", - r"repodata/.*\.(xml|xml\.gz|xml\.bz2|xml\.xz|xml\.zck|xml\.zst" - r"|sqlite|sqlite\.gz|sqlite\.bz2|sqlite\.xz|sqlite\.zck|sqlite\.zst" - r"|yaml\.xz|yaml\.gz|yaml\.bz2|yaml\.zst|asc|txt)$", - r"Packages\.gz$", - ], - "docker": [ - r"/manifests/(?!sha256:)[^/]+$", - r"/tags/list$", - ], - "pypi": [ - r"simple/", # Per-package and top-level simple index pages - ], - "npm": [], - "helm": [ - r"index\.yaml$", - ], - "puppet": [ - r"^v3/modules/", - r"^v3/releases", - ], - "terraform": [ - r"[^/]+/[^/]+/versions$", - ], - "generic": [], -} - - -class ConfigManager: - def __init__(self, config_path: str = "remotes.yaml"): - self.config_path = config_path - self._config_dir: str | None = None - self._last_modified: float = 0.0 - self.config = self._load_config() - - def _load_single_file(self, path: str) -> dict: - try: - with open(path) as f: - if path.endswith((".yaml", ".yml")): - return yaml.safe_load(f) or {} - return json.load(f) - except FileNotFoundError: - return {} - - @staticmethod - def _merge(base: dict, overlay: dict) -> dict: - result = {**base} - for key, value in overlay.items(): - if key in ("remotes", "virtuals", "locals") and isinstance(base.get(key), dict) and isinstance(value, dict): - result[key] = {**base.get(key, {}), **value} - else: - result[key] = value - return result - - def _load_from_dir(self, dir_path: str) -> dict: - merged: dict = {} - files = sorted(glob.glob(os.path.join(dir_path, "*.yaml")) + glob.glob(os.path.join(dir_path, "*.yml"))) - for path in files: - merged = self._merge(merged, self._load_single_file(path)) - return merged - - def _load_config(self) -> dict: - self._config_dir = None - - if os.path.isdir(self.config_path): - return self._load_from_dir(self.config_path) or {"remotes": {}, "virtuals": {}, "locals": {}} - - config = self._load_single_file(self.config_path) - if not config: - return {"remotes": {}, "virtuals": {}, "locals": {}} - - config_dir = config.pop("config_dir", None) - if config_dir: - if not os.path.isabs(config_dir): - config_dir = os.path.join(os.path.dirname(os.path.abspath(self.config_path)), config_dir) - self._config_dir = config_dir - config = self._merge(config, self._load_from_dir(config_dir)) - - return config - - def _file_mtimes(self) -> list[float]: - mtimes: list[float] = [] - if os.path.isdir(self.config_path): - for f in glob.glob(os.path.join(self.config_path, "*.yaml")) + glob.glob(os.path.join(self.config_path, "*.yml")): - try: - mtimes.append(os.path.getmtime(f)) - except OSError: - pass - else: - try: - mtimes.append(os.path.getmtime(self.config_path)) - except OSError: - pass - - if self._config_dir and os.path.isdir(self._config_dir): - for f in glob.glob(os.path.join(self._config_dir, "*.yaml")) + glob.glob(os.path.join(self._config_dir, "*.yml")): - try: - mtimes.append(os.path.getmtime(f)) - except OSError: - pass - - return mtimes - - def _check_reload(self) -> None: - try: - current_modified = max(self._file_mtimes(), default=0.0) - if current_modified > self._last_modified: - self._last_modified = current_modified - self.config = self._load_config() - print(f"Config reloaded from {self.config_path}") - except OSError: - pass - - def get_remote_config(self, remote_name: str) -> dict | None: - self._check_reload() - 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]: - remote_config = self.get_remote_config(remote_name) - if not remote_config: - return [] - - repositories = remote_config.get("repositories", {}) - - if isinstance(repositories, dict): - repo_config = repositories.get(repo_path) - if repo_config: - patterns = repo_config.get("immutable_patterns", []) - else: - patterns = remote_config.get("immutable_patterns", []) - else: - patterns = remote_config.get("immutable_patterns", []) - - return patterns - - def get_s3_config(self) -> dict: - """Get S3 configuration from environment variables""" - endpoint = os.getenv("MINIO_ENDPOINT") - access_key = os.getenv("MINIO_ACCESS_KEY") - secret_key = os.getenv("MINIO_SECRET_KEY") - bucket = os.getenv("MINIO_BUCKET") - - if not endpoint: - raise ValueError("MINIO_ENDPOINT environment variable is required") - if not access_key: - raise ValueError("MINIO_ACCESS_KEY environment variable is required") - if not secret_key: - raise ValueError("MINIO_SECRET_KEY environment variable is required") - if not bucket: - raise ValueError("MINIO_BUCKET environment variable is required") - - return { - "endpoint": endpoint, - "access_key": access_key, - "secret_key": secret_key, - "bucket": bucket, - "secure": os.getenv("MINIO_SECURE", "false").lower() == "true", - } - - def get_redis_config(self) -> dict: - """Get Redis configuration from environment variables""" - redis_url = os.getenv("REDIS_URL") - if not redis_url: - raise ValueError("REDIS_URL environment variable is required") - - return {"url": redis_url} - - def get_database_config(self) -> dict: - """Get database configuration from environment variables""" - db_host = os.getenv("DBHOST") - db_port = os.getenv("DBPORT") - db_user = os.getenv("DBUSER") - db_pass = os.getenv("DBPASS") - db_name = os.getenv("DBNAME") - - if not all([db_host, db_port, db_user, db_pass, db_name]): - missing = [ - var - for var, val in [("DBHOST", db_host), ("DBPORT", db_port), ("DBUSER", db_user), ("DBPASS", db_pass), ("DBNAME", db_name)] - if not val - ] - raise ValueError(f"All database environment variables are required: {', '.join(missing)}") - - db_url = f"postgresql://{db_user}:{db_pass}@{db_host}:{db_port}/{db_name}" - return {"url": db_url} - - def get_user_mutable_patterns(self, remote_name: str) -> list[str]: - """Return only user-configured mutable_patterns, excluding package-type defaults.""" - remote_config = self.get_remote_config(remote_name) - if not remote_config: - return [] - return remote_config.get("mutable_patterns", []) - - def get_mutable_patterns(self, remote_name: str) -> list[str]: - """Return mutable-file patterns for a remote (TTL is configured per-remote in cache.index_ttl). - - Merges the package-level defaults with any extra patterns listed under - ``mutable_patterns`` in the remote's config. - """ - remote_config = self.get_remote_config(remote_name) - if not remote_config: - return [] - package = remote_config.get("package", "generic") - defaults = _PACKAGE_MUTABLE_PATTERNS.get(package, []) - extra = remote_config.get("mutable_patterns", []) - return defaults + [p for p in extra if p not in defaults] - - def get_cache_config(self, remote_name: str) -> dict: - """Get cache configuration for a specific remote""" - remote_config = self.get_remote_config(remote_name) - if not remote_config: - return {} - - return remote_config.get("cache", {}) - - def get_quarantine_config(self, remote_name: str) -> tuple[bool, int]: - """Return (enabled, quarantine_days) for a remote. - - When enabled=True and quarantine_days>0, immutable artifacts published - within the last quarantine_days days are blocked with a 404. - """ - remote_config = self.get_remote_config(remote_name) - if not remote_config: - return False, 0 - enabled = bool(remote_config.get("quarantine_new", False)) - days = int(remote_config.get("quarantine_days", 0)) - return enabled, days diff --git a/src/artifactapi/database/__init__.py b/src/artifactapi/database/__init__.py deleted file mode 100644 index 2a4fd1c..0000000 --- a/src/artifactapi/database/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .postgres import DatabaseManager - -__all__ = ["DatabaseManager"] diff --git a/src/artifactapi/database/postgres.py b/src/artifactapi/database/postgres.py deleted file mode 100644 index 733c131..0000000 --- a/src/artifactapi/database/postgres.py +++ /dev/null @@ -1,258 +0,0 @@ -import psycopg2 -from psycopg2.extras import RealDictCursor - - -class DatabaseManager: - def __init__(self, db_url: str): - self.db_url = db_url - self.available = False - self._init_database() - - def _init_database(self): - try: - self.connection = psycopg2.connect(self.db_url) - self.connection.autocommit = True - self._create_schema() - self.available = True - print("Database connection established") - except Exception as e: - print(f"Database not available: {e}") - self.available = False - - def _create_schema(self): - try: - with self.connection.cursor() as cursor: - cursor.execute(""" - CREATE TABLE IF NOT EXISTS artifact_mappings ( - id SERIAL PRIMARY KEY, - s3_key VARCHAR(255) UNIQUE NOT NULL, - remote_name VARCHAR(100) NOT NULL, - file_path TEXT NOT NULL, - size_bytes BIGINT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - """) - - cursor.execute(""" - CREATE TABLE IF NOT EXISTS local_files ( - id SERIAL PRIMARY KEY, - repository_name VARCHAR(100) NOT NULL, - file_path TEXT NOT NULL, - s3_key VARCHAR(255) UNIQUE NOT NULL, - size_bytes BIGINT NOT NULL, - sha256_sum VARCHAR(64) NOT NULL, - content_type VARCHAR(100), - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE(repository_name, file_path) - ) - """) - - cursor.execute("CREATE INDEX IF NOT EXISTS idx_s3_key ON artifact_mappings (s3_key)") - cursor.execute("CREATE INDEX IF NOT EXISTS idx_remote_name ON artifact_mappings (remote_name)") - cursor.execute("CREATE INDEX IF NOT EXISTS idx_local_repo_path ON local_files (repository_name, file_path)") - cursor.execute("CREATE INDEX IF NOT EXISTS idx_local_s3_key ON local_files (s3_key)") - print("Database schema initialized") - except Exception as e: - print(f"Error creating schema: {e}") - - def record_artifact_mapping(self, s3_key: str, remote_name: str, file_path: str, size_bytes: int): - if not self.available: - return - - try: - with self.connection.cursor() as cursor: - cursor.execute( - """ - INSERT INTO artifact_mappings (s3_key, remote_name, file_path, size_bytes) - VALUES (%s, %s, %s, %s) - ON CONFLICT (s3_key) - DO UPDATE SET - remote_name = EXCLUDED.remote_name, - file_path = EXCLUDED.file_path, - size_bytes = EXCLUDED.size_bytes - """, - (s3_key, remote_name, file_path, size_bytes), - ) - except Exception as e: - print(f"Error recording artifact mapping: {e}") - - def get_storage_by_remote(self) -> dict[str, int]: - if not self.available: - return {} - - try: - with self.connection.cursor(cursor_factory=RealDictCursor) as cursor: - cursor.execute(""" - SELECT remote_name, SUM(size_bytes) as total_size - FROM artifact_mappings - GROUP BY remote_name - """) - results = cursor.fetchall() - return {row["remote_name"]: row["total_size"] or 0 for row in results} - except Exception as e: - print(f"Error getting storage by remote: {e}") - return {} - - def get_remote_for_s3_key(self, s3_key: str) -> str | None: - if not self.available: - return None - - try: - with self.connection.cursor() as cursor: - cursor.execute( - "SELECT remote_name FROM artifact_mappings WHERE s3_key = %s", - (s3_key,), - ) - result = cursor.fetchone() - return result[0] if result else None - except Exception as e: - print(f"Error getting remote for S3 key: {e}") - return None - - def add_local_file( - self, - repository_name: str, - file_path: str, - s3_key: str, - size_bytes: int, - sha256_sum: str, - content_type: str = None, - ): - if not self.available: - return False - - try: - with self.connection.cursor() as cursor: - cursor.execute( - """ - INSERT INTO local_files (repository_name, file_path, s3_key, size_bytes, sha256_sum, content_type) - VALUES (%s, %s, %s, %s, %s, %s) - """, - ( - repository_name, - file_path, - s3_key, - size_bytes, - sha256_sum, - content_type, - ), - ) - self.connection.commit() - return True - except Exception as e: - print(f"Error adding local file: {e}") - return False - - def get_local_file_metadata(self, repository_name: str, file_path: str): - if not self.available: - return None - - try: - with self.connection.cursor() as cursor: - cursor.execute( - """ - SELECT repository_name, file_path, s3_key, size_bytes, sha256_sum, content_type, created_at, uploaded_at - FROM local_files - WHERE repository_name = %s AND file_path = %s - """, - (repository_name, file_path), - ) - result = cursor.fetchone() - if result: - return { - "repository_name": result[0], - "file_path": result[1], - "s3_key": result[2], - "size_bytes": result[3], - "sha256_sum": result[4], - "content_type": result[5], - "created_at": result[6], - "uploaded_at": result[7], - } - return None - except Exception as e: - print(f"Error getting local file metadata: {e}") - return None - - def list_local_files(self, repository_name: str, prefix: str = ""): - if not self.available: - return [] - - try: - with self.connection.cursor() as cursor: - if prefix: - cursor.execute( - """ - SELECT file_path, size_bytes, sha256_sum, content_type, created_at, uploaded_at - FROM local_files - WHERE repository_name = %s AND file_path LIKE %s - ORDER BY file_path - """, - (repository_name, f"{prefix}%"), - ) - else: - cursor.execute( - """ - SELECT file_path, size_bytes, sha256_sum, content_type, created_at, uploaded_at - FROM local_files - WHERE repository_name = %s - ORDER BY file_path - """, - (repository_name,), - ) - - results = cursor.fetchall() - return [ - { - "file_path": result[0], - "size_bytes": result[1], - "sha256_sum": result[2], - "content_type": result[3], - "created_at": result[4], - "uploaded_at": result[5], - } - for result in results - ] - except Exception as e: - print(f"Error listing local files: {e}") - return [] - - def delete_local_file(self, repository_name: str, file_path: str): - if not self.available: - return False - - try: - with self.connection.cursor() as cursor: - cursor.execute( - """ - DELETE FROM local_files - WHERE repository_name = %s AND file_path = %s - RETURNING s3_key - """, - (repository_name, file_path), - ) - result = cursor.fetchone() - self.connection.commit() - return result[0] if result else None - except Exception as e: - print(f"Error deleting local file: {e}") - return None - - def file_exists(self, repository_name: str, file_path: str): - if not self.available: - return False - - try: - with self.connection.cursor() as cursor: - cursor.execute( - """ - SELECT 1 FROM local_files - WHERE repository_name = %s AND file_path = %s - """, - (repository_name, file_path), - ) - return cursor.fetchone() is not None - except Exception as e: - print(f"Error checking file existence: {e}") - return False diff --git a/src/artifactapi/docker_auth.py b/src/artifactapi/docker_auth.py deleted file mode 100644 index c331c3f..0000000 --- a/src/artifactapi/docker_auth.py +++ /dev/null @@ -1,19 +0,0 @@ -from .auth.docker import ( - _cache_key, - _get_cached_token, - _store_token, - _token_cache, - fetch_token, - get_docker_token_for_response, - parse_www_authenticate, -) - -__all__ = [ - "_cache_key", - "_get_cached_token", - "_store_token", - "_token_cache", - "fetch_token", - "get_docker_token_for_response", - "parse_www_authenticate", -] diff --git a/src/artifactapi/main.py b/src/artifactapi/main.py deleted file mode 100644 index ae9d566..0000000 --- a/src/artifactapi/main.py +++ /dev/null @@ -1,145 +0,0 @@ -import logging -import os - -from fastapi import FastAPI, File, Query, Request, UploadFile -from fastapi.responses import PlainTextResponse -from prometheus_client import CONTENT_TYPE_LATEST, generate_latest -from pydantic import BaseModel - -try: - from importlib.metadata import version - - __version__ = version("artifactapi") -except ImportError: - __version__ = "dev" - -from .artifact import discovery, flush, local, proxy, virtual -from .artifact import docker as docker_handler -from .cache import RedisCache -from .config import ConfigManager -from .database import DatabaseManager -from .metrics import MetricsManager -from .storage import S3Storage - -logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s") -logger = logging.getLogger(__name__) - -app = FastAPI(title="Artifact Storage API", version=__version__) - -config_path = os.environ.get("CONFIG_PATH") -if not config_path: - raise ValueError("CONFIG_PATH environment variable is required") -config = ConfigManager(config_path) - -s3_config = config.get_s3_config() -redis_config = config.get_redis_config() -db_config = config.get_database_config() - -storage = S3Storage(**s3_config) -cache = RedisCache(redis_config["url"]) -database = DatabaseManager(db_config["url"]) -metrics = MetricsManager(cache, database) - - -class ArtifactRequest(BaseModel): - remote: str - include_pattern: str - - -@app.get("/") -def read_root(): - config._check_reload() - return { - "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") -def health_check(): - return {"status": "healthy"} - - -@app.get("/config") -def get_config(): - return config.config - - -@app.get("/metrics") -def get_metrics(json: bool | None = Query(False, description="Return JSON format instead of Prometheus")): - config._check_reload() - if json: - return metrics.get_metrics(storage, config) - metrics.get_metrics(storage, config) - return PlainTextResponse(generate_latest().decode("utf-8"), media_type=CONTENT_TYPE_LATEST) - - -@app.put("/cache/flush") -def flush_cache( - remote: str = Query(default=None, description="Specific remote to flush (optional)"), - cache_type: str = Query(default="all", description="Type to flush: 'all', 'index', 'files', 'metrics'"), -): - return flush.handle(remote, cache_type, cache, storage) - - -@app.get("/v2/") -async def docker_v2_ping(): - return docker_handler.ping() - - -@app.api_route("/v2/{remote_name}/{path:path}", methods=["GET", "HEAD"]) -async def docker_v2_proxy(request: Request, remote_name: str, path: str): - return await docker_handler.proxy(request, remote_name, path, storage, cache, config, metrics) - - -@app.get("/api/v1/virtual/{virtual_name}/{path:path}") -async def get_virtual_artifact(request: Request, virtual_name: str, path: str): - return await virtual.handle(request, virtual_name, path, storage, cache, config) - - -@app.get("/api/v1/remote/{remote_name}/{path:path}") -async def get_artifact(request: Request, remote_name: str, path: str): - return await proxy.handle(request, remote_name, path, storage, cache, config, database, metrics) - - -@app.get("/api/v1/local/{local_name}/{path:path}") -def get_local_artifact(local_name: str, path: str): - return local.download(local_name, path, storage, database, config) - - -@app.put("/api/v1/local/{local_name}/{path:path}") -async def upload_local_file(local_name: str, path: str, file: UploadFile = File(...)): - return await local.upload(local_name, path, file, storage, database, config) - - -@app.head("/api/v1/local/{local_name}/{path:path}") -def check_local_file_exists(local_name: str, path: str): - return local.check_exists(local_name, path, 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") -async def cache_artifact(request: ArtifactRequest): - return await discovery.cache_artifacts(request.remote, request.include_pattern, storage) - - -@app.get("/api/v1/artifacts/{remote:path}") -async def list_cached_artifacts(remote: str, include_pattern: str = ".*"): - return await discovery.list_artifacts(remote, include_pattern, storage) - - -def main(): - import uvicorn - - uvicorn.run(app, host="0.0.0.0", port=8000) - - -if __name__ == "__main__": - main() diff --git a/src/artifactapi/metrics.py b/src/artifactapi/metrics.py deleted file mode 100644 index 04473fb..0000000 --- a/src/artifactapi/metrics.py +++ /dev/null @@ -1,202 +0,0 @@ -from datetime import datetime -from typing import Any - -from prometheus_client import Counter, Gauge - -# Prometheus metrics -request_counter = Counter("artifact_requests_total", "Total artifact requests", ["remote", "status"]) -cache_hit_counter = Counter("artifact_cache_hits_total", "Total cache hits", ["remote"]) -cache_miss_counter = Counter("artifact_cache_misses_total", "Total cache misses", ["remote"]) -bandwidth_saved_counter = Counter("artifact_bandwidth_saved_bytes_total", "Total bandwidth saved", ["remote"]) -storage_size_gauge = Gauge("artifact_storage_size_bytes", "Storage size by remote", ["remote"]) -redis_keys_gauge = Gauge("artifact_redis_keys_total", "Total Redis keys") - - -class MetricsManager: - def __init__(self, redis_client=None, database_manager=None): - self.redis_client = redis_client - self.database_manager = database_manager - self.start_time = datetime.now() - - def record_cache_hit(self, remote_name: str, size_bytes: int): - """Record a cache hit with size for bandwidth calculation""" - # Update Prometheus metrics - request_counter.labels(remote=remote_name, status="cache_hit").inc() - cache_hit_counter.labels(remote=remote_name).inc() - bandwidth_saved_counter.labels(remote=remote_name).inc(size_bytes) - - # Update Redis for persistence across instances - if self.redis_client and self.redis_client.available: - try: - # Increment global counters - self.redis_client.client.incr("metrics:cache_hits") - self.redis_client.client.incr("metrics:total_requests") - self.redis_client.client.incrby("metrics:bandwidth_saved", size_bytes) - - # Increment per-remote counters - self.redis_client.client.incr(f"metrics:cache_hits:{remote_name}") - self.redis_client.client.incr(f"metrics:total_requests:{remote_name}") - self.redis_client.client.incrby(f"metrics:bandwidth_saved:{remote_name}", size_bytes) - except Exception: - pass - - def record_cache_miss(self, remote_name: str, size_bytes: int): - """Record a cache miss (new download)""" - # Update Prometheus metrics - request_counter.labels(remote=remote_name, status="cache_miss").inc() - cache_miss_counter.labels(remote=remote_name).inc() - - # Update Redis for persistence across instances - if self.redis_client and self.redis_client.available: - try: - # Increment global counters - self.redis_client.client.incr("metrics:cache_misses") - self.redis_client.client.incr("metrics:total_requests") - - # Increment per-remote counters - self.redis_client.client.incr(f"metrics:cache_misses:{remote_name}") - self.redis_client.client.incr(f"metrics:total_requests:{remote_name}") - except Exception: - pass - - def get_redis_key_count(self) -> int: - """Get total number of keys in Redis""" - if self.redis_client and self.redis_client.available: - try: - return self.redis_client.client.dbsize() - except Exception: - return 0 - return 0 - - def get_s3_total_size(self, storage) -> int: - """Get total size of all objects in S3 bucket""" - try: - total_size = 0 - paginator = storage.client.get_paginator("list_objects_v2") - for page in paginator.paginate(Bucket=storage.bucket): - if "Contents" in page: - for obj in page["Contents"]: - total_size += obj["Size"] - return total_size - except Exception: - return 0 - - def get_s3_size_by_remote(self, storage, config_manager) -> dict[str, int]: - """Get size of stored data per remote using database mappings""" - if self.database_manager and self.database_manager.available: - # Get from database if available - db_sizes = self.database_manager.get_storage_by_remote() - if db_sizes: - # Initialize all configured remotes and locals to 0 - remote_sizes = {} - all_names = list(config_manager.config.get("remotes", {}).keys()) + list(config_manager.config.get("locals", {}).keys()) - for remote in all_names: - remote_sizes[remote] = db_sizes.get(remote, 0) - - # Update Prometheus gauges - for remote, size in remote_sizes.items(): - storage_size_gauge.labels(remote=remote).set(size) - - return remote_sizes - - # Fallback to S3 scanning if database not available - try: - remote_sizes = {} - all_names = list(config_manager.config.get("remotes", {}).keys()) + list(config_manager.config.get("locals", {}).keys()) - - # Initialize all remotes and locals to 0 - for remote in all_names: - remote_sizes[remote] = 0 - - paginator = storage.client.get_paginator("list_objects_v2") - for page in paginator.paginate(Bucket=storage.bucket): - if "Contents" in page: - for obj in page["Contents"]: - key = obj["Key"] - # Try to map from database first - remote = None - if self.database_manager: - remote = self.database_manager.get_remote_for_s3_key(key) - - # Fallback to key parsing - if not remote: - remote = key.split("/")[0] if "/" in key else "unknown" - - if remote in remote_sizes: - remote_sizes[remote] += obj["Size"] - else: - remote_sizes.setdefault("unknown", 0) - remote_sizes["unknown"] += obj["Size"] - - # Update Prometheus gauges - for remote, size in remote_sizes.items(): - if remote != "unknown": # Don't set gauge for unknown - storage_size_gauge.labels(remote=remote).set(size) - - return remote_sizes - except Exception: - return {} - - def get_metrics(self, storage, config_manager) -> dict[str, Any]: - """Get comprehensive metrics""" - # Update Redis keys gauge - redis_key_count = self.get_redis_key_count() - redis_keys_gauge.set(redis_key_count) - - metrics = { - "timestamp": datetime.now().isoformat(), - "uptime_seconds": int((datetime.now() - self.start_time).total_seconds()), - "redis": {"total_keys": redis_key_count}, - "storage": { - "total_size_bytes": self.get_s3_total_size(storage), - "size_by_remote": self.get_s3_size_by_remote(storage, config_manager), - }, - "requests": { - "cache_hits": 0, - "cache_misses": 0, - "total_requests": 0, - "cache_hit_ratio": 0.0, - }, - "bandwidth": {"saved_bytes": 0}, - "per_remote": {}, - } - - if self.redis_client and self.redis_client.available: - try: - # Get global metrics - cache_hits = int(self.redis_client.client.get("metrics:cache_hits") or 0) - cache_misses = int(self.redis_client.client.get("metrics:cache_misses") or 0) - total_requests = cache_hits + cache_misses - bandwidth_saved = int(self.redis_client.client.get("metrics:bandwidth_saved") or 0) - - metrics["requests"]["cache_hits"] = cache_hits - metrics["requests"]["cache_misses"] = cache_misses - metrics["requests"]["total_requests"] = total_requests - metrics["requests"]["cache_hit_ratio"] = cache_hits / total_requests if total_requests > 0 else 0.0 - metrics["bandwidth"]["saved_bytes"] = bandwidth_saved - - # Get per-repo metrics - all_repos = { - **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_misses = int(self.redis_client.client.get(f"metrics:cache_misses:{remote}") or 0) - remote_total = remote_cache_hits + remote_cache_misses - remote_bandwidth_saved = int(self.redis_client.client.get(f"metrics:bandwidth_saved:{remote}") or 0) - - metrics["per_remote"][remote] = { - "cache_hits": remote_cache_hits, - "cache_misses": remote_cache_misses, - "total_requests": remote_total, - "cache_hit_ratio": remote_cache_hits / remote_total if remote_total > 0 else 0.0, - "bandwidth_saved_bytes": remote_bandwidth_saved, - "storage_size_bytes": metrics["storage"]["size_by_remote"].get(remote, 0), - } - - except Exception: - pass - - return metrics diff --git a/src/artifactapi/remote/__init__.py b/src/artifactapi/remote/__init__.py deleted file mode 100644 index 225f8c5..0000000 --- a/src/artifactapi/remote/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from . import generic, helm, npm, puppet, python, rpm, terraform -from .base import get_content_type - -__all__ = ["generic", "helm", "npm", "puppet", "python", "rpm", "terraform", "get_content_type"] diff --git a/src/artifactapi/remote/base.py b/src/artifactapi/remote/base.py deleted file mode 100644 index ce5f523..0000000 --- a/src/artifactapi/remote/base.py +++ /dev/null @@ -1,16 +0,0 @@ -def get_content_type(filename: str) -> str: - if filename.endswith((".tar.gz", ".tgz")): - return "application/gzip" - if filename.endswith(".zip") or filename.endswith(".whl"): - return "application/zip" - if filename.endswith(".exe"): - return "application/x-msdownload" - if filename.endswith(".rpm"): - return "application/x-rpm" - if filename.endswith(".xml"): - return "application/xml" - if filename.endswith((".xml.gz", ".xml.bz2", ".xml.xz")): - return "application/gzip" - if filename.endswith((".yaml", ".yml")): - return "text/yaml" - return "application/octet-stream" diff --git a/src/artifactapi/remote/generic.py b/src/artifactapi/remote/generic.py deleted file mode 100644 index 3a41962..0000000 --- a/src/artifactapi/remote/generic.py +++ /dev/null @@ -1,3 +0,0 @@ -from .base import get_content_type - -__all__ = ["get_content_type"] diff --git a/src/artifactapi/remote/helm.py b/src/artifactapi/remote/helm.py deleted file mode 100644 index dc0aa79..0000000 --- a/src/artifactapi/remote/helm.py +++ /dev/null @@ -1,18 +0,0 @@ -from .base import get_content_type - - -def resolve_content( - data: bytes, - path: str, - filename: str, - base_url: str, - proxy_url: str, - remote_name: str, -) -> tuple[bytes, str]: - if filename == "index.yaml": - data = data.replace( - base_url.encode(), - f"{proxy_url}/api/v1/remote/{remote_name}".encode(), - ) - return data, "text/yaml" - return data, get_content_type(filename) diff --git a/src/artifactapi/remote/npm.py b/src/artifactapi/remote/npm.py deleted file mode 100644 index 3547b2d..0000000 --- a/src/artifactapi/remote/npm.py +++ /dev/null @@ -1,21 +0,0 @@ -import re - -from .base import get_content_type - - -def resolve_content( - data: bytes, - path: str, - filename: str, - immutable_patterns: list[str], - base_url: str, - proxy_url: str, - remote_name: str, -) -> tuple[bytes, str]: - if not any(re.search(p, path) for p in immutable_patterns): - data = data.replace( - base_url.encode(), - f"{proxy_url}/api/v1/remote/{remote_name}".encode(), - ) - return data, "application/json" - return data, get_content_type(filename) diff --git a/src/artifactapi/remote/puppet.py b/src/artifactapi/remote/puppet.py deleted file mode 100644 index 758bbf0..0000000 --- a/src/artifactapi/remote/puppet.py +++ /dev/null @@ -1,24 +0,0 @@ -from .base import get_content_type - - -def resolve_content( - data: bytes, - path: str, - filename: str, - base_url: str, - proxy_url: str, - remote_name: str, -) -> tuple[bytes, str]: - if not path.startswith("v3/files/"): - proxy_remote_url = f"{proxy_url}/api/v1/remote/{remote_name}" - # Rewrite any absolute forge API URLs - data = data.replace(base_url.encode(), proxy_remote_url.encode()) - # Rewrite relative file_uri paths ("/v3/files/...") to absolute proxy URLs. - # g10k resolves file_uri against only the forge host, so a relative path - # would drop our /api/v1/remote/ prefix. - data = data.replace( - b'"/v3/files/', - f'"{proxy_remote_url}/v3/files/'.encode(), - ) - return data, "application/json" - return data, get_content_type(filename) diff --git a/src/artifactapi/remote/python.py b/src/artifactapi/remote/python.py deleted file mode 100644 index bed8d2d..0000000 --- a/src/artifactapi/remote/python.py +++ /dev/null @@ -1,32 +0,0 @@ -import re - -from .base import get_content_type - - -def construct_url(base_url: str, path: str) -> str: - """Build the upstream URL for a PyPI request. - - PyPI splits simple/ index pages (pypi.org) from file downloads - (files.pythonhosted.org), so simple/ requests are redirected to pypi.org. - """ - if base_url.rstrip("/") == "https://files.pythonhosted.org" and "simple/" in path: - return f"https://pypi.org/{path}" - return f"{base_url}/{path}" - - -def resolve_content( - data: bytes, - path: str, - filename: str, - immutable_patterns: list[str], - base_url: str, - proxy_url: str, - remote_name: str, -) -> tuple[bytes, str]: - if not any(re.search(p, path) for p in immutable_patterns): - data = data.replace( - base_url.encode(), - f"{proxy_url}/api/v1/remote/{remote_name}".encode(), - ) - return data, "text/html; charset=utf-8" - return data, get_content_type(filename) diff --git a/src/artifactapi/remote/rpm.py b/src/artifactapi/remote/rpm.py deleted file mode 100644 index 3a41962..0000000 --- a/src/artifactapi/remote/rpm.py +++ /dev/null @@ -1,3 +0,0 @@ -from .base import get_content_type - -__all__ = ["get_content_type"] diff --git a/src/artifactapi/remote/terraform.py b/src/artifactapi/remote/terraform.py deleted file mode 100644 index ab89ff8..0000000 --- a/src/artifactapi/remote/terraform.py +++ /dev/null @@ -1,36 +0,0 @@ -import json -import re -from urllib.parse import urlparse - -from .base import get_content_type - -_DOWNLOAD_PATH = re.compile(r"^[^/]+/[^/]+/[^/]+/download/[^/]+/[^/]+$") - - -def construct_url(base_url: str, path: str) -> str: - return f"{base_url}/v1/providers/{path}" - - -def resolve_content( - data: bytes, - path: str, - filename: str, - _base_url: str, - proxy_url: str, - _remote_name: str, - releases_remote: str | None = None, -) -> tuple[bytes, str]: - if filename.endswith((".zip", ".sig")): - return data, get_content_type(filename) - if releases_remote and _DOWNLOAD_PATH.match(path): - releases_proxy = f"{proxy_url}/api/v1/remote/{releases_remote}" - try: - obj = json.loads(data) - for field in ("download_url", "shasums_url", "shasums_signature_url"): - if field in obj: - parsed = urlparse(obj[field]) - obj[field] = f"{releases_proxy}{parsed.path}" - data = json.dumps(obj).encode() - except (json.JSONDecodeError, KeyError): - pass - return data, "application/json" diff --git a/src/artifactapi/storage/__init__.py b/src/artifactapi/storage/__init__.py deleted file mode 100644 index 64272bd..0000000 --- a/src/artifactapi/storage/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .s3 import S3Storage - -__all__ = ["S3Storage"] diff --git a/src/artifactapi/storage/s3.py b/src/artifactapi/storage/s3.py deleted file mode 100644 index b2bcbc6..0000000 --- a/src/artifactapi/storage/s3.py +++ /dev/null @@ -1,114 +0,0 @@ -import hashlib -import os - -import boto3 -from botocore.config import Config -from botocore.exceptions import ClientError -from fastapi import HTTPException - - -class S3Storage: - def __init__( - self, - endpoint: str, - access_key: str, - secret_key: str, - bucket: str, - secure: bool = False, - ): - self.endpoint = endpoint - self.access_key = access_key - self.secret_key = secret_key - self.bucket = bucket - self.secure = secure - - ca_bundle = os.environ.get("REQUESTS_CA_BUNDLE") or os.environ.get("SSL_CERT_FILE") - config_kwargs = {"request_checksum_calculation": "when_required", "response_checksum_validation": "when_required"} - client_kwargs = { - "endpoint_url": f"http{'s' if self.secure else ''}://{self.endpoint}", - "aws_access_key_id": self.access_key, - "aws_secret_access_key": self.secret_key, - "config": Config(**config_kwargs), - } - - if ca_bundle and os.path.exists(ca_bundle): - client_kwargs["verify"] = ca_bundle - print(f"Debug: Using CA bundle: {ca_bundle}") - else: - print( - f"Debug: No CA bundle found. REQUESTS_CA_BUNDLE={os.environ.get('REQUESTS_CA_BUNDLE')}, SSL_CERT_FILE={os.environ.get('SSL_CERT_FILE')}" - ) - - self.client = boto3.client("s3", **client_kwargs) - - try: - self._ensure_bucket_exists() - except Exception as e: - print(f"Warning: Could not ensure bucket exists during initialization: {e}") - print("Bucket creation will be attempted on first use") - - def _ensure_bucket_exists(self): - try: - self.client.head_bucket(Bucket=self.bucket) - except ClientError: - self.client.create_bucket(Bucket=self.bucket) - - def get_object_key(self, remote_name: str, path: str) -> str: - clean_path = path.lstrip("/") - filename = os.path.basename(clean_path) - directory_path = os.path.dirname(clean_path) - - # Docker blobs are keyed by digest for deduplication across images - if "/blobs/sha256:" in clean_path: - parts = clean_path.split("/blobs/sha256:") - if len(parts) == 2: - digest = parts[1] - return f"{remote_name}/blobs/sha256/{digest}" - - if directory_path: - path_hash = hashlib.sha256(directory_path.encode()).hexdigest()[:16] - return f"{remote_name}/{path_hash}/{filename}" - else: - return f"{remote_name}/{filename}" - - def exists(self, key: str) -> bool: - try: - self._ensure_bucket_exists() - self.client.head_object(Bucket=self.bucket, Key=key) - return True - except ClientError: - return False - - def upload(self, key: str, data: bytes) -> str: - self._ensure_bucket_exists() - self.client.put_object(Bucket=self.bucket, Key=key, Body=data) - return f"s3://{self.bucket}/{key}" - - def get_url(self, key: str) -> str: - return f"http://{self.endpoint}/{self.bucket}/{key}" - - def get_presigned_url(self, key: str, expiration: int = 3600) -> str: - try: - return self.client.generate_presigned_url( - "get_object", - Params={"Bucket": self.bucket, "Key": key}, - ExpiresIn=expiration, - ) - except Exception: - return self.get_url(key) - - def download_object(self, key: str) -> bytes: - try: - self._ensure_bucket_exists() - response = self.client.get_object(Bucket=self.bucket, Key=key) - return response["Body"].read() - except ClientError: - raise HTTPException(status_code=404, detail="Artifact not found") - - def delete_object(self, key: str) -> bool: - try: - self._ensure_bucket_exists() - self.client.delete_object(Bucket=self.bucket, Key=key) - return True - except ClientError: - return False diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index 2659f14..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1,220 +0,0 @@ -""" -Pytest configuration and shared fixtures. - -Module-level setup (env vars + connection patches) runs before any test -module is imported, so the FastAPI app initialises against mocks rather -than real S3 / Redis / PostgreSQL services. -""" - -import os -import tempfile -from unittest.mock import MagicMock, patch - -import yaml - -# --------------------------------------------------------------------------- -# Test remote configuration -# --------------------------------------------------------------------------- - -TEST_REMOTES = { - "remotes": { - "alpine-test": { - "base_url": "https://dl-cdn.alpinelinux.org", - "package": "alpine", - "immutable_patterns": [".*/x86_64/.*\\.apk$"], - "cache": {"immutable_ttl": 0, "mutable_ttl": 3600}, - }, - "rpm-test": { - "base_url": "https://example.com/rpm", - "package": "rpm", - "immutable_patterns": [".*/x86_64/.*\\.rpm$", ".*/repodata/.*$"], - "cache": {"immutable_ttl": 0, "mutable_ttl": 3600}, - }, - "docker-test": { - "base_url": "https://registry.example.com", - "package": "docker", - "cache": {"immutable_ttl": 0, "mutable_ttl": 300}, - }, - "docker-restricted": { - "base_url": "https://registry.example.com", - "package": "docker", - "immutable_patterns": ["^library/nginx"], - "cache": {"immutable_ttl": 0, "mutable_ttl": 300}, - }, - "docker-bantags-test": { - "base_url": "https://registry.example.com", - "package": "docker", - "ban_tags_enabled": True, - "ban_tags": ["latest", "edge"], - "cache": {"immutable_ttl": 0, "mutable_ttl": 300}, - }, - "generic-test": { - "base_url": "https://releases.example.com", - "package": "generic", - "immutable_patterns": [".*\\.tar\\.gz$"], - "cache": {"immutable_ttl": 0, "mutable_ttl": 0}, - }, - "custom-index-test": { - "base_url": "https://example.com", - "package": "generic", - "mutable_patterns": ["metadata\\.json$"], - "cache": {"immutable_ttl": 0, "mutable_ttl": 600}, - }, - "check-mutable-test": { - "base_url": "https://example.com", - "package": "generic", - "mutable_patterns": ["metadata\\.json$"], - "check_mutable_updates": True, - "cache": {"immutable_ttl": 0, "mutable_ttl": 600}, - }, - "pypi-test": { - "base_url": "https://files.pythonhosted.org", - "package": "pypi", - "immutable_patterns": [ - r"packages/.*\.whl$", - r"packages/.*\.whl\.metadata$", - r"packages/.*\.tar\.gz$", - ], - "cache": {"immutable_ttl": 0, "mutable_ttl": 600}, - }, - "npm-test": { - "base_url": "https://registry.npmjs.org", - "package": "npm", - "immutable_patterns": [r"\.tgz$"], - "mutable_patterns": [r"^(?!.*\.tgz$).*"], - "cache": {"immutable_ttl": 0, "mutable_ttl": 600}, - }, - "helm-test": { - "base_url": "https://helm.releases.hashicorp.com", - "package": "helm", - "immutable_patterns": [r"\.tgz$"], - "cache": {"immutable_ttl": 0, "mutable_ttl": 3600}, - }, - "quarantine-test": { - "base_url": "https://releases.example.com", - "package": "generic", - "immutable_patterns": [r".*\.tar\.gz$"], - "quarantine_new": True, - "quarantine_days": 3, - "cache": {"immutable_ttl": 0, "mutable_ttl": 0}, - }, - "quarantine-disabled": { - "base_url": "https://releases.example.com", - "package": "generic", - "immutable_patterns": [r".*\.tar\.gz$"], - "quarantine_new": False, - "quarantine_days": 3, - "cache": {"immutable_ttl": 0, "mutable_ttl": 0}, - }, - "helm-member-2": { - "base_url": "https://charts.example.com", - "package": "helm", - "immutable_patterns": [r"\.tgz$"], - "cache": {"immutable_ttl": 0, "mutable_ttl": 1800}, - }, - "puppet-test": { - "base_url": "https://forgeapi.puppet.com", - "package": "puppet", - "immutable_patterns": [r"^v3/files/.*\.tar\.gz$"], - "cache": {"immutable_ttl": 0, "mutable_ttl": 600}, - }, - "terraform-registry-test": { - "base_url": "https://registry.terraform.io", - "package": "terraform", - "immutable_patterns": [ - r"[^/]+/[^/]+/[^/]+/download/[^/]+/[^/]+$", - ], - "releases_remote": "hashicorp-releases-test", - "cache": {"immutable_ttl": 0, "mutable_ttl": 300}, - }, - "hashicorp-releases-test": { - "base_url": "https://releases.hashicorp.com", - "package": "generic", - "immutable_patterns": [r".*\.zip$", r".*SHA256SUMS(\.sig)?$"], - "cache": {"immutable_ttl": 0, "mutable_ttl": 0}, - }, - }, - "locals": { - "local-test": { - "package": "generic", - "cache": {"immutable_ttl": 0, "mutable_ttl": 0}, - }, - }, - "virtuals": { - "helm-virtual-test": { - "package": "helm", - "members": ["helm-test", "helm-member-2"], - }, - "unsupported-virtual-test": { - "package": "rpm", - "members": ["rpm-test"], - }, - "empty-virtual-test": { - "package": "helm", - "members": [], - }, - }, -} - -# --------------------------------------------------------------------------- -# Write temp config and set env vars BEFORE importing the package -# --------------------------------------------------------------------------- - -_tmpdir = tempfile.mkdtemp() -_config_path = os.path.join(_tmpdir, "remotes.yaml") -with open(_config_path, "w") as _f: - yaml.dump(TEST_REMOTES, _f) - -os.environ.update( - { - "CONFIG_PATH": _config_path, - "MINIO_ENDPOINT": "localhost:9000", - "MINIO_ACCESS_KEY": "testkey", - "MINIO_SECRET_KEY": "testsecret", - "MINIO_BUCKET": "testbucket", - "REDIS_URL": "redis://localhost:6379/0", - "DBHOST": "localhost", - "DBPORT": "5432", - "DBUSER": "test", - "DBPASS": "test", - "DBNAME": "test", - } -) - -# Patch external service connections before the package is imported. -# These stay active for the whole session (process exits after tests finish). -_boto3_patch = patch("boto3.client", return_value=MagicMock()) -_redis_patch = patch("redis.from_url", return_value=MagicMock()) -_psycopg2_patch = patch("psycopg2.connect", return_value=MagicMock()) -_boto3_patch.start() -_redis_patch.start() -_psycopg2_patch.start() - -# --------------------------------------------------------------------------- -# Shared fixtures -# --------------------------------------------------------------------------- - -import pytest # noqa: E402 -from fastapi.testclient import TestClient # noqa: E402 - - -@pytest.fixture(scope="session") -def app(): - from artifactapi.main import app as fastapi_app - - return fastapi_app - - -@pytest.fixture(scope="session") -def client(app): - return TestClient(app) - - -@pytest.fixture -def config_path(): - return _config_path - - -@pytest.fixture -def test_remotes(): - return TEST_REMOTES diff --git a/tests/test_cache.py b/tests/test_cache.py deleted file mode 100644 index 2c19593..0000000 --- a/tests/test_cache.py +++ /dev/null @@ -1,400 +0,0 @@ -"""Tests for RedisCache, focusing on is_mutable_file with configurable patterns.""" - -import hashlib -from unittest.mock import ANY, MagicMock, patch - -import pytest - -from artifactapi.cache import RedisCache -from artifactapi.config import _PACKAGE_MUTABLE_PATTERNS - - -@pytest.fixture -def bare_cache(): - """RedisCache instance bypassing __init__ (no Redis needed for pure-logic tests).""" - return RedisCache.__new__(RedisCache) - - -@pytest.fixture -def unavailable_cache(): - """RedisCache where Redis is not reachable.""" - with patch("redis.from_url", side_effect=Exception("connection refused")): - return RedisCache("redis://localhost:6379/0") - - -@pytest.fixture -def mock_redis_client(): - return MagicMock() - - -@pytest.fixture -def cache_with_redis(mock_redis_client): - """RedisCache backed by a MagicMock Redis client.""" - with patch("redis.from_url", return_value=mock_redis_client): - c = RedisCache("redis://localhost:6379/0") - c.client = mock_redis_client - c.available = True - return c - - -# --------------------------------------------------------------------------- -# is_mutable_file — alpine patterns -# --------------------------------------------------------------------------- - - -class TestIsMutableFileAlpine: - def test_apkindex_tarball_is_index(self, bare_cache): - patterns = _PACKAGE_MUTABLE_PATTERNS["alpine"] - assert bare_cache.is_mutable_file("alpine/v3.18/x86_64/APKINDEX.tar.gz", patterns) - - def test_nested_apkindex_is_index(self, bare_cache): - patterns = _PACKAGE_MUTABLE_PATTERNS["alpine"] - assert bare_cache.is_mutable_file("mirrors/dl-cdn/alpine/v3.19/community/x86_64/APKINDEX.tar.gz", patterns) - - def test_apk_package_is_not_index(self, bare_cache): - patterns = _PACKAGE_MUTABLE_PATTERNS["alpine"] - assert not bare_cache.is_mutable_file("alpine/v3.18/x86_64/musl-1.2.4-r2.apk", patterns) - - def test_random_tarball_is_not_index(self, bare_cache): - patterns = _PACKAGE_MUTABLE_PATTERNS["alpine"] - assert not bare_cache.is_mutable_file("some/path/archive.tar.gz", patterns) - - def test_apkindex_signature_file_is_not_index(self, bare_cache): - # Signature file adjacent to the index should not be treated as an index - patterns = _PACKAGE_MUTABLE_PATTERNS["alpine"] - assert not bare_cache.is_mutable_file("alpine/v3.18/x86_64/APKINDEX.tar.gz.sig", patterns) - - def test_apkindex_tmp_file_is_not_index(self, bare_cache): - patterns = _PACKAGE_MUTABLE_PATTERNS["alpine"] - assert not bare_cache.is_mutable_file("alpine/v3.18/x86_64/APKINDEX.tar.gz.tmp", patterns) - - -# --------------------------------------------------------------------------- -# is_mutable_file — rpm patterns -# --------------------------------------------------------------------------- - - -class TestIsMutableFileRpm: - def test_repomd_xml_is_index(self, bare_cache): - patterns = _PACKAGE_MUTABLE_PATTERNS["rpm"] - assert bare_cache.is_mutable_file("almalinux/9/x86_64/repomd.xml", patterns) - - def test_repodata_primary_xml_gz_is_index(self, bare_cache): - patterns = _PACKAGE_MUTABLE_PATTERNS["rpm"] - assert bare_cache.is_mutable_file("repo/repodata/primary.xml.gz", patterns) - - def test_repodata_sqlite_is_index(self, bare_cache): - patterns = _PACKAGE_MUTABLE_PATTERNS["rpm"] - assert bare_cache.is_mutable_file("repo/repodata/primary.sqlite", patterns) - - def test_repodata_sqlite_bz2_is_index(self, bare_cache): - patterns = _PACKAGE_MUTABLE_PATTERNS["rpm"] - assert bare_cache.is_mutable_file("repo/repodata/other.sqlite.bz2", patterns) - - def test_repodata_yaml_xz_is_index(self, bare_cache): - patterns = _PACKAGE_MUTABLE_PATTERNS["rpm"] - assert bare_cache.is_mutable_file("repo/repodata/comps.yaml.xz", patterns) - - def test_packages_gz_pattern_matches_any_path(self, bare_cache): - # The Packages.gz$ regex is a carryover from the original hardcoded logic and - # deliberately matches any path ending in Packages.gz — including Debian-style paths. - # This test documents that intentional behaviour. - patterns = _PACKAGE_MUTABLE_PATTERNS["rpm"] - assert bare_cache.is_mutable_file("debian/dists/stable/main/binary-amd64/Packages.gz", patterns) - - def test_rpm_package_is_not_index(self, bare_cache): - patterns = _PACKAGE_MUTABLE_PATTERNS["rpm"] - assert not bare_cache.is_mutable_file("almalinux/9/x86_64/Packages/bash-5.1.8.x86_64.rpm", patterns) - - def test_arbitrary_xml_outside_repodata_is_not_index(self, bare_cache): - patterns = _PACKAGE_MUTABLE_PATTERNS["rpm"] - assert not bare_cache.is_mutable_file("some/path/config.xml", patterns) - - -# --------------------------------------------------------------------------- -# is_mutable_file — docker patterns -# --------------------------------------------------------------------------- - - -class TestIsMutableFileDocker: - def test_tag_manifest_is_index(self, bare_cache): - patterns = _PACKAGE_MUTABLE_PATTERNS["docker"] - assert bare_cache.is_mutable_file("library/nginx/manifests/latest", patterns) - - def test_version_tag_manifest_is_index(self, bare_cache): - patterns = _PACKAGE_MUTABLE_PATTERNS["docker"] - assert bare_cache.is_mutable_file("library/nginx/manifests/1.25.3", patterns) - - def test_hyphenated_tag_manifest_is_index(self, bare_cache): - patterns = _PACKAGE_MUTABLE_PATTERNS["docker"] - assert bare_cache.is_mutable_file("library/nginx/manifests/latest-rc", patterns) - - def test_numeric_date_tag_manifest_is_index(self, bare_cache): - patterns = _PACKAGE_MUTABLE_PATTERNS["docker"] - assert bare_cache.is_mutable_file("library/nginx/manifests/20240101", patterns) - - def test_digest_manifest_is_not_index(self, bare_cache): - patterns = _PACKAGE_MUTABLE_PATTERNS["docker"] - digest = "sha256:" + "a" * 64 - assert not bare_cache.is_mutable_file(f"library/nginx/manifests/{digest}", patterns) - - def test_tags_list_is_index(self, bare_cache): - patterns = _PACKAGE_MUTABLE_PATTERNS["docker"] - assert bare_cache.is_mutable_file("library/nginx/tags/list", patterns) - - def test_blob_is_not_index(self, bare_cache): - patterns = _PACKAGE_MUTABLE_PATTERNS["docker"] - assert not bare_cache.is_mutable_file("library/nginx/blobs/sha256:abc123", patterns) - - -# --------------------------------------------------------------------------- -# is_mutable_file — edge cases -# --------------------------------------------------------------------------- - - -class TestIsMutableFileEdgeCases: - def test_empty_patterns_nothing_is_index(self, bare_cache): - assert not bare_cache.is_mutable_file("APKINDEX.tar.gz", []) - assert not bare_cache.is_mutable_file("repomd.xml", []) - assert not bare_cache.is_mutable_file("library/nginx/manifests/latest", []) - - def test_none_patterns_nothing_is_index(self, bare_cache): - assert not bare_cache.is_mutable_file("APKINDEX.tar.gz", None) - assert not bare_cache.is_mutable_file("repomd.xml", None) - - def test_custom_patterns_match(self, bare_cache): - patterns = [r"metadata\.json$", r"index\.yaml$"] - assert bare_cache.is_mutable_file("repo/metadata.json", patterns) - assert bare_cache.is_mutable_file("repo/subdir/index.yaml", patterns) - assert not bare_cache.is_mutable_file("repo/data.tar.gz", patterns) - - def test_custom_pattern_does_not_match_standard_index(self, bare_cache): - patterns = [r"metadata\.json$"] - assert not bare_cache.is_mutable_file("APKINDEX.tar.gz", patterns) - - -# --------------------------------------------------------------------------- -# get_index_cache_key -# --------------------------------------------------------------------------- - - -class TestGetIndexCacheKey: - def test_key_format_is_deterministic(self, bare_cache): - # Assert against a pre-computed value to pin the hash algorithm, - # truncation length, and format string in one assertion. - path = "alpine/v3.18/x86_64/APKINDEX.tar.gz" - expected_hash = hashlib.sha256(path.encode()).hexdigest()[:16] - key = bare_cache.get_index_cache_key("alpine-test", path) - assert key == f"index:alpine-test:{expected_hash}" - - def test_different_paths_produce_different_keys(self, bare_cache): - k1 = bare_cache.get_index_cache_key("alpine-test", "alpine/v3.18/x86_64/APKINDEX.tar.gz") - k2 = bare_cache.get_index_cache_key("alpine-test", "alpine/v3.19/x86_64/APKINDEX.tar.gz") - assert k1 != k2 - - def test_different_remotes_produce_different_keys(self, bare_cache): - k1 = bare_cache.get_index_cache_key("remote-a", "path/to/APKINDEX.tar.gz") - k2 = bare_cache.get_index_cache_key("remote-b", "path/to/APKINDEX.tar.gz") - assert k1 != k2 - - def test_key_starts_with_index_prefix_and_remote(self, bare_cache): - key = bare_cache.get_index_cache_key("myremote", "some/path") - assert key.startswith("index:myremote:") - - def test_key_hash_segment_is_16_chars(self, bare_cache): - key = bare_cache.get_index_cache_key("myremote", "some/path/file.xml") - # Format: index::<16-char hash> — the fixed length matters for key-space hygiene - parts = key.split(":") - assert len(parts) == 3 - assert len(parts[2]) == 16 - - -# --------------------------------------------------------------------------- -# mark_index_cached / is_index_valid -# --------------------------------------------------------------------------- - - -class TestIndexValidity: - def test_mark_index_cached_calls_setex_with_correct_ttl(self, cache_with_redis, mock_redis_client): - cache_with_redis.mark_index_cached("remote", "path/APKINDEX.tar.gz", 300) - expected_key = cache_with_redis.get_index_cache_key("remote", "path/APKINDEX.tar.gz") - mock_redis_client.setex.assert_called_once_with(expected_key, 300, ANY) - - def test_present_key_is_valid(self, cache_with_redis, mock_redis_client): - mock_redis_client.exists.return_value = 1 - assert cache_with_redis.is_index_valid("remote", "path/APKINDEX.tar.gz") - - def test_missing_key_is_not_valid(self, cache_with_redis, mock_redis_client): - mock_redis_client.exists.return_value = 0 - assert not cache_with_redis.is_index_valid("remote", "path/APKINDEX.tar.gz") - - def test_unavailable_redis_is_not_valid(self, unavailable_cache): - assert not unavailable_cache.is_index_valid("remote", "some/path") - - def test_mark_cached_no_op_when_unavailable(self, unavailable_cache): - # client is None when Redis is unavailable — setex cannot be called - assert unavailable_cache.client is None - unavailable_cache.mark_index_cached("remote", "some/path", 300) # must not raise - - -# --------------------------------------------------------------------------- -# mutable meta (ETag / Last-Modified storage) -# --------------------------------------------------------------------------- - - -class TestMutableMeta: - def test_meta_key_format(self, bare_cache): - path = "repo/metadata.json" - expected_hash = hashlib.sha256(path.encode()).hexdigest()[:16] - assert bare_cache.get_mutable_meta_key("myremote", path) == f"mutable:meta:myremote:{expected_hash}" - - def test_meta_key_hash_is_16_chars(self, bare_cache): - key = bare_cache.get_mutable_meta_key("remote", "some/path/file.json") - assert len(key.split(":")[-1]) == 16 - - def test_store_and_retrieve_etag(self, cache_with_redis, mock_redis_client): - mock_redis_client.hgetall.return_value = {"etag": '"abc123"'} - cache_with_redis.store_mutable_meta("remote", "path/meta.json", '"abc123"', None) - mock_redis_client.hset.assert_called_once() - meta = cache_with_redis.get_mutable_meta("remote", "path/meta.json") - assert meta["etag"] == '"abc123"' - - def test_store_and_retrieve_last_modified(self, cache_with_redis, mock_redis_client): - lm = "Mon, 01 Jan 2024 00:00:00 GMT" - mock_redis_client.hgetall.return_value = {"last_modified": lm} - cache_with_redis.store_mutable_meta("remote", "path/meta.json", None, lm) - meta = cache_with_redis.get_mutable_meta("remote", "path/meta.json") - assert meta["last_modified"] == lm - - def test_store_no_op_when_both_none(self, cache_with_redis, mock_redis_client): - cache_with_redis.store_mutable_meta("remote", "path/meta.json", None, None) - mock_redis_client.hset.assert_not_called() - - def test_store_no_op_when_unavailable(self, unavailable_cache): - unavailable_cache.store_mutable_meta("remote", "path", "etag", None) # must not raise - - def test_get_returns_empty_when_unavailable(self, unavailable_cache): - assert unavailable_cache.get_mutable_meta("remote", "path") == {} - - def test_delete_removes_meta_key(self, cache_with_redis, mock_redis_client): - expected_key = cache_with_redis.get_mutable_meta_key("remote", "path/meta.json") - cache_with_redis.delete_mutable_meta("remote", "path/meta.json") - mock_redis_client.delete.assert_called_once_with(expected_key) - - def test_delete_no_op_when_unavailable(self, unavailable_cache): - unavailable_cache.delete_mutable_meta("remote", "path") # must not raise - - -# --------------------------------------------------------------------------- -# artifact published date (quarantine support) -# --------------------------------------------------------------------------- - - -class TestArtifactPublished: - def test_key_format_is_deterministic(self, bare_cache): - path = "some/path/package-1.0.tar.gz" - expected_hash = hashlib.sha256(path.encode()).hexdigest()[:16] - assert bare_cache.get_artifact_published_key("myremote", path) == f"pkg:published:myremote:{expected_hash}" - - def test_key_hash_is_16_chars(self, bare_cache): - key = bare_cache.get_artifact_published_key("remote", "path/to/file.whl") - assert len(key.split(":")[-1]) == 16 - - def test_different_paths_produce_different_keys(self, bare_cache): - k1 = bare_cache.get_artifact_published_key("remote", "pkg-1.0.tar.gz") - k2 = bare_cache.get_artifact_published_key("remote", "pkg-2.0.tar.gz") - assert k1 != k2 - - def test_store_calls_set_with_correct_value(self, cache_with_redis, mock_redis_client): - lm = "Mon, 01 Jan 2024 00:00:00 GMT" - cache_with_redis.store_artifact_published("remote", "path/pkg.tar.gz", lm) - expected_key = cache_with_redis.get_artifact_published_key("remote", "path/pkg.tar.gz") - mock_redis_client.set.assert_called_once_with(expected_key, lm) - - def test_get_returns_stored_value(self, cache_with_redis, mock_redis_client): - lm = "Tue, 15 Mar 2022 12:00:00 GMT" - mock_redis_client.get.return_value = lm - result = cache_with_redis.get_artifact_published("remote", "path/pkg.tar.gz") - assert result == lm - - def test_get_returns_none_when_not_stored(self, cache_with_redis, mock_redis_client): - mock_redis_client.get.return_value = None - result = cache_with_redis.get_artifact_published("remote", "path/pkg.tar.gz") - assert result is None - - def test_store_no_op_when_unavailable(self, unavailable_cache): - unavailable_cache.store_artifact_published("remote", "path", "Mon, 01 Jan 2024 00:00:00 GMT") - - def test_get_returns_none_when_unavailable(self, unavailable_cache): - assert unavailable_cache.get_artifact_published("remote", "path") is None - - -# --------------------------------------------------------------------------- -# fetch lock (thundering-herd deduplication) -# --------------------------------------------------------------------------- - - -class TestFetchLock: - def test_acquire_returns_true_when_lock_obtained(self, cache_with_redis, mock_redis_client): - mock_redis_client.set.return_value = True - result = cache_with_redis.acquire_fetch_lock("myremote", "library/nginx/manifests/latest") - assert result is True - - def test_acquire_calls_set_nx_with_ttl(self, cache_with_redis, mock_redis_client): - mock_redis_client.set.return_value = True - cache_with_redis.acquire_fetch_lock("myremote", "library/nginx/manifests/latest", ttl=15) - _, kwargs = mock_redis_client.set.call_args - assert kwargs.get("nx") is True - assert kwargs.get("ex") == 15 - - def test_acquire_returns_false_when_lock_already_held(self, cache_with_redis, mock_redis_client): - mock_redis_client.set.return_value = None # Redis SET NX → None when key exists - result = cache_with_redis.acquire_fetch_lock("myremote", "library/nginx/manifests/latest") - assert result is False - - def test_acquire_fails_open_when_unavailable(self, unavailable_cache): - # caller must be allowed to proceed when Redis is down - assert unavailable_cache.acquire_fetch_lock("myremote", "some/path") is True - - def test_acquire_fails_open_on_redis_exception(self, cache_with_redis, mock_redis_client): - mock_redis_client.set.side_effect = Exception("connection reset") - assert cache_with_redis.acquire_fetch_lock("myremote", "some/path") is True - - def test_lock_key_embeds_path_hash(self, cache_with_redis, mock_redis_client): - mock_redis_client.set.return_value = True - path = "library/nginx/manifests/latest" - cache_with_redis.acquire_fetch_lock("myremote", path) - args, _ = mock_redis_client.set.call_args - expected_hash = hashlib.sha256(path.encode()).hexdigest()[:16] - assert args[0] == f"fetchlock:myremote:{expected_hash}" - - def test_lock_key_hash_is_16_chars(self, cache_with_redis, mock_redis_client): - mock_redis_client.set.return_value = True - cache_with_redis.acquire_fetch_lock("myremote", "some/long/path/file.tar.gz") - args, _ = mock_redis_client.set.call_args - # key format: fetchlock::<16-char hash> - parts = args[0].split(":") - assert len(parts) == 3 - assert len(parts[2]) == 16 - - def test_different_paths_produce_different_lock_keys(self, cache_with_redis, mock_redis_client): - mock_redis_client.set.return_value = True - cache_with_redis.acquire_fetch_lock("myremote", "path/a/manifests/latest") - key_a = mock_redis_client.set.call_args[0][0] - mock_redis_client.set.reset_mock() - cache_with_redis.acquire_fetch_lock("myremote", "path/b/manifests/latest") - key_b = mock_redis_client.set.call_args[0][0] - assert key_a != key_b - - def test_release_deletes_correct_key(self, cache_with_redis, mock_redis_client): - path = "library/nginx/manifests/latest" - cache_with_redis.release_fetch_lock("myremote", path) - expected_hash = hashlib.sha256(path.encode()).hexdigest()[:16] - mock_redis_client.delete.assert_called_once_with(f"fetchlock:myremote:{expected_hash}") - - def test_release_no_op_when_unavailable(self, unavailable_cache): - unavailable_cache.release_fetch_lock("myremote", "some/path") # must not raise - - def test_release_no_op_on_redis_exception(self, cache_with_redis, mock_redis_client): - mock_redis_client.delete.side_effect = Exception("timeout") - cache_with_redis.release_fetch_lock("myremote", "some/path") # must not raise diff --git a/tests/test_config.py b/tests/test_config.py deleted file mode 100644 index bb719b7..0000000 --- a/tests/test_config.py +++ /dev/null @@ -1,540 +0,0 @@ -"""Tests for ConfigManager, focusing on get_mutable_patterns and get_immutable_patterns.""" - -import os - -import pytest -import yaml - -from artifactapi.config import ConfigManager - - -@pytest.fixture -def make_config(tmp_path): - """Factory: write a remotes dict to a temp YAML and return a ConfigManager.""" - - def _make(remotes_dict): - cfg_file = tmp_path / "remotes.yaml" - cfg_file.write_text(yaml.dump({"remotes": remotes_dict})) - return ConfigManager(str(cfg_file)) - - return _make - - -# --------------------------------------------------------------------------- -# get_mutable_patterns -# --------------------------------------------------------------------------- - - -class TestGetMutablePatterns: - def test_alpine_returns_package_defaults(self, make_config): - cfg = make_config({"r": {"package": "alpine", "base_url": "https://x.com"}}) - patterns = cfg.get_mutable_patterns("r") - assert r"APKINDEX\.tar\.gz$" in patterns - - def test_rpm_returns_package_defaults(self, make_config): - cfg = make_config({"r": {"package": "rpm", "base_url": "https://x.com"}}) - patterns = cfg.get_mutable_patterns("r") - assert r"repomd\.xml$" in patterns - assert any("repodata" in p for p in patterns) - - def test_docker_returns_package_defaults(self, make_config): - cfg = make_config({"r": {"package": "docker", "base_url": "https://x.com"}}) - patterns = cfg.get_mutable_patterns("r") - assert any("manifests" in p for p in patterns) - assert any("tags/list" in p for p in patterns) - - def test_generic_returns_empty_list(self, make_config): - cfg = make_config({"r": {"package": "generic", "base_url": "https://x.com"}}) - assert cfg.get_mutable_patterns("r") == [] - - def test_unknown_remote_returns_empty_list(self, make_config): - cfg = make_config({}) - assert cfg.get_mutable_patterns("nonexistent") == [] - - def test_missing_package_field_defaults_to_generic(self, make_config): - cfg = make_config({"r": {"base_url": "https://x.com"}}) - assert cfg.get_mutable_patterns("r") == [] - - def test_unknown_package_type_returns_empty_list(self, make_config): - # A mis-spelled package type silently returns [] — this is a known footgun - cfg = make_config({"r": {"package": "deb", "base_url": "https://x.com"}}) - assert cfg.get_mutable_patterns("r") == [] - - def test_extra_patterns_appended_after_defaults(self, make_config): - cfg = make_config( - { - "r": { - "type": "remote", - "package": "alpine", - "base_url": "https://x.com", - "mutable_patterns": [r"custom\.json$"], - } - } - ) - patterns = cfg.get_mutable_patterns("r") - assert r"APKINDEX\.tar\.gz$" in patterns - assert r"custom\.json$" in patterns - # Defaults come first - assert patterns.index(r"APKINDEX\.tar\.gz$") < patterns.index(r"custom\.json$") - - def test_explicit_empty_extra_patterns_returns_defaults(self, make_config): - cfg = make_config( - { - "r": { - "type": "remote", - "package": "alpine", - "base_url": "https://x.com", - "mutable_patterns": [], - } - } - ) - assert r"APKINDEX\.tar\.gz$" in cfg.get_mutable_patterns("r") - - def test_duplicate_extra_pattern_not_added_twice(self, make_config): - existing = r"APKINDEX\.tar\.gz$" - cfg = make_config( - { - "r": { - "type": "remote", - "package": "alpine", - "base_url": "https://x.com", - "mutable_patterns": [existing], - } - } - ) - patterns = cfg.get_mutable_patterns("r") - assert patterns.count(existing) == 1 - - def test_generic_with_only_extra_patterns(self, make_config): - cfg = make_config( - { - "r": { - "type": "remote", - "package": "generic", - "base_url": "https://x.com", - "mutable_patterns": [r"meta\.json$", r"index\.yaml$"], - } - } - ) - assert cfg.get_mutable_patterns("r") == [r"meta\.json$", r"index\.yaml$"] - - def test_rpm_extra_patterns_merged(self, make_config): - cfg = make_config( - { - "r": { - "type": "remote", - "package": "rpm", - "base_url": "https://x.com", - "mutable_patterns": [r"custom-meta\.xml$"], - } - } - ) - patterns = cfg.get_mutable_patterns("r") - assert r"repomd\.xml$" in patterns - assert r"custom-meta\.xml$" in patterns - - def test_npm_has_no_package_defaults(self, make_config): - cfg = make_config({"r": {"package": "npm", "base_url": "https://x.com"}}) - assert cfg.get_mutable_patterns("r") == [] - - def test_npm_explicit_mutable_pattern_matches_metadata(self, make_config): - import re - - cfg = make_config( - { - "r": { - "type": "remote", - "package": "npm", - "base_url": "https://x.com", - "mutable_patterns": [r"^(?!.*\.tgz$).*"], - } - } - ) - patterns = cfg.get_mutable_patterns("r") - assert any(re.search(p, "express") for p in patterns) - assert any(re.search(p, "@babel/core") for p in patterns) - - def test_helm_returns_index_yaml_as_mutable(self, make_config): - cfg = make_config({"r": {"package": "helm", "base_url": "https://helm.example.com"}}) - patterns = cfg.get_mutable_patterns("r") - assert r"index\.yaml$" in patterns - - def test_helm_chart_tarballs_not_mutable_by_default(self, make_config): - import re - - cfg = make_config({"r": {"package": "helm", "base_url": "https://helm.example.com"}}) - patterns = cfg.get_mutable_patterns("r") - # Only index.yaml is mutable; .tgz chart tarballs are not - assert not any(re.search(p, "vault-0.29.1.tgz") for p in patterns) - assert not any(re.search(p, "consul-1.5.0.tgz") for p in patterns) - - def test_npm_explicit_mutable_pattern_excludes_tarballs(self, make_config): - import re - - cfg = make_config( - { - "r": { - "type": "remote", - "package": "npm", - "base_url": "https://x.com", - "mutable_patterns": [r"^(?!.*\.tgz$).*"], - } - } - ) - patterns = cfg.get_mutable_patterns("r") - assert not any(re.search(p, "express-4.18.2.tgz") for p in patterns) - assert not any(re.search(p, "express/-/express-4.18.2.tgz") for p in patterns) - - -# --------------------------------------------------------------------------- -# get_immutable_patterns -# --------------------------------------------------------------------------- - - -class TestGetImmutablePatterns: - def test_returns_immutable_patterns(self, make_config): - cfg = make_config( - { - "r": { - "type": "remote", - "package": "generic", - "base_url": "https://x.com", - "immutable_patterns": [r".*\.tar\.gz$"], - } - } - ) - assert cfg.get_immutable_patterns("r") == [r".*\.tar\.gz$"] - - def test_returns_empty_for_missing_remote(self, make_config): - cfg = make_config({}) - assert cfg.get_immutable_patterns("nonexistent") == [] - - def test_returns_empty_when_no_patterns_configured(self, make_config): - cfg = make_config({"r": {"package": "generic", "base_url": "https://x.com"}}) - assert cfg.get_immutable_patterns("r") == [] - - def test_multiple_patterns_returned(self, make_config): - patterns = [r".*\.rpm$", r".*/repodata/.*$"] - cfg = make_config( - { - "r": { - "type": "remote", - "package": "rpm", - "base_url": "https://x.com", - "immutable_patterns": patterns, - } - } - ) - assert cfg.get_immutable_patterns("r") == patterns - - def test_dict_keyed_repositories_returns_per_repo_patterns(self, make_config): - cfg = make_config( - { - "r": { - "type": "remote", - "package": "generic", - "base_url": "https://x.com", - "immutable_patterns": [r".*\.tar\.gz$"], - "repositories": { - "/path/to/repo": {"immutable_patterns": [r".*\.rpm$"]}, - }, - } - } - ) - assert cfg.get_immutable_patterns("r", "/path/to/repo") == [r".*\.rpm$"] - - def test_dict_keyed_repositories_falls_back_to_remote_patterns(self, make_config): - cfg = make_config( - { - "r": { - "type": "remote", - "package": "generic", - "base_url": "https://x.com", - "immutable_patterns": [r".*\.tar\.gz$"], - "repositories": { - "/path/to/repo": {"immutable_patterns": [r".*\.rpm$"]}, - }, - } - } - ) - assert cfg.get_immutable_patterns("r", "/unknown/path") == [r".*\.tar\.gz$"] - - -# --------------------------------------------------------------------------- -# get_user_mutable_patterns -# --------------------------------------------------------------------------- - - -class TestGetUserMutablePatterns: - def test_returns_only_user_patterns(self, make_config): - cfg = make_config( - { - "r": { - "type": "remote", - "package": "alpine", - "base_url": "https://x.com", - "mutable_patterns": [r"custom\.json$"], - } - } - ) - assert cfg.get_user_mutable_patterns("r") == [r"custom\.json$"] - - def test_excludes_package_defaults(self, make_config): - # Package defaults (APKINDEX etc.) must NOT appear here - cfg = make_config({"r": {"package": "alpine", "base_url": "https://x.com"}}) - assert cfg.get_user_mutable_patterns("r") == [] - - def test_returns_empty_for_missing_remote(self, make_config): - cfg = make_config({}) - assert cfg.get_user_mutable_patterns("nonexistent") == [] - - def test_returns_empty_when_key_absent(self, make_config): - cfg = make_config({"r": {"package": "generic", "base_url": "https://x.com"}}) - assert cfg.get_user_mutable_patterns("r") == [] - - -# --------------------------------------------------------------------------- -# get_cache_config -# --------------------------------------------------------------------------- - - -class TestGetCacheConfig: - def test_returns_cache_section(self, make_config): - cfg = make_config( - { - "r": { - "type": "remote", - "package": "generic", - "base_url": "https://x.com", - "cache": {"immutable_ttl": 0, "mutable_ttl": 7200}, - } - } - ) - assert cfg.get_cache_config("r") == {"immutable_ttl": 0, "mutable_ttl": 7200} - - def test_returns_empty_dict_for_missing_remote(self, make_config): - cfg = make_config({}) - assert cfg.get_cache_config("nonexistent") == {} - - def test_returns_empty_dict_when_no_cache_key(self, make_config): - cfg = make_config({"r": {"package": "generic", "base_url": "https://x.com"}}) - assert cfg.get_cache_config("r") == {} - - -# --------------------------------------------------------------------------- -# Config file reload -# --------------------------------------------------------------------------- - - -class TestConfigReload: - def test_reloads_when_file_mtime_advances(self, tmp_path): - cfg_file = tmp_path / "remotes.yaml" - cfg_file.write_text(yaml.dump({"remotes": {"repo-a": {"package": "generic", "base_url": "https://x.com"}}})) - cfg = ConfigManager(str(cfg_file)) - assert "repo-a" in cfg.config["remotes"] - - cfg_file.write_text(yaml.dump({"remotes": {"repo-b": {"package": "generic", "base_url": "https://y.com"}}})) - future_mtime = cfg._last_modified + 1 - os.utime(str(cfg_file), (future_mtime, future_mtime)) - - cfg._check_reload() - - assert "repo-b" in cfg.config["remotes"] - assert "repo-a" not in cfg.config["remotes"] - - def test_no_reload_when_file_unchanged(self, tmp_path): - cfg_file = tmp_path / "remotes.yaml" - cfg_file.write_text(yaml.dump({"remotes": {"repo-a": {"package": "generic", "base_url": "https://x.com"}}})) - cfg = ConfigManager(str(cfg_file)) - - # Call check_reload without touching the file — should not reload - cfg._check_reload() - - assert "repo-a" in cfg.config["remotes"] - - -# --------------------------------------------------------------------------- -# get_quarantine_config -# --------------------------------------------------------------------------- - - -class TestGetQuarantineConfig: - def test_returns_false_zero_when_not_configured(self, make_config): - cfg = make_config({"r": {"package": "generic", "base_url": "https://x.com"}}) - enabled, days = cfg.get_quarantine_config("r") - assert enabled is False - assert days == 0 - - def test_returns_false_zero_for_missing_remote(self, make_config): - cfg = make_config({}) - enabled, days = cfg.get_quarantine_config("nonexistent") - assert enabled is False - assert days == 0 - - def test_enabled_true_and_days_returned(self, make_config): - cfg = make_config( - { - "r": { - "type": "remote", - "package": "generic", - "base_url": "https://x.com", - "quarantine_new": True, - "quarantine_days": 7, - } - } - ) - enabled, days = cfg.get_quarantine_config("r") - assert enabled is True - assert days == 7 - - def test_quarantine_new_false_returns_disabled(self, make_config): - cfg = make_config( - { - "r": { - "type": "remote", - "package": "generic", - "base_url": "https://x.com", - "quarantine_new": False, - "quarantine_days": 7, - } - } - ) - enabled, days = cfg.get_quarantine_config("r") - assert enabled is False - assert days == 7 - - def test_enabled_with_zero_days_returns_zero(self, make_config): - cfg = make_config( - { - "r": { - "type": "remote", - "package": "generic", - "base_url": "https://x.com", - "quarantine_new": True, - "quarantine_days": 0, - } - } - ) - enabled, days = cfg.get_quarantine_config("r") - assert enabled is True - assert days == 0 - - -# --------------------------------------------------------------------------- -# Directory mode (CONFIG_PATH points to a directory) -# --------------------------------------------------------------------------- - - -def _remote(base_url: str = "https://x.com") -> dict: - return {"package": "generic", "base_url": base_url} - - -class TestConfigDirMode: - def test_loads_all_yaml_files(self, tmp_path): - (tmp_path / "a.yaml").write_text(yaml.dump({"remotes": {"repo-a": _remote()}})) - (tmp_path / "b.yaml").write_text(yaml.dump({"remotes": {"repo-b": _remote("https://y.com")}})) - cfg = ConfigManager(str(tmp_path)) - assert "repo-a" in cfg.config["remotes"] - assert "repo-b" in cfg.config["remotes"] - - 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 / "b.yaml").write_text(yaml.dump({"remotes": {"r": _remote("https://second.com")}})) - cfg = ConfigManager(str(tmp_path)) - assert cfg.config["remotes"]["r"]["base_url"] == "https://second.com" - - def test_empty_directory_returns_empty_remotes(self, tmp_path): - cfg = ConfigManager(str(tmp_path)) - assert cfg.config == {"remotes": {}, "virtuals": {}, "locals": {}} - - def test_ignores_non_yaml_files(self, tmp_path): - (tmp_path / "notes.txt").write_text("not yaml") - (tmp_path / "a.yaml").write_text(yaml.dump({"remotes": {"repo-a": _remote()}})) - cfg = ConfigManager(str(tmp_path)) - assert list(cfg.config["remotes"].keys()) == ["repo-a"] - - def test_reload_picks_up_new_file(self, tmp_path): - (tmp_path / "a.yaml").write_text(yaml.dump({"remotes": {"repo-a": _remote()}})) - cfg = ConfigManager(str(tmp_path)) - assert "repo-a" in cfg.config["remotes"] - assert "repo-b" not in cfg.config["remotes"] - - new_file = tmp_path / "b.yaml" - new_file.write_text(yaml.dump({"remotes": {"repo-b": _remote("https://y.com")}})) - future_mtime = cfg._last_modified + 1 - os.utime(str(new_file), (future_mtime, future_mtime)) - - cfg._check_reload() - - assert "repo-a" in cfg.config["remotes"] - assert "repo-b" in cfg.config["remotes"] - - -# --------------------------------------------------------------------------- -# config_dir key (main file contains a config_dir pointer) -# --------------------------------------------------------------------------- - - -class TestConfigDirKey: - def test_merges_remotes_from_config_dir(self, tmp_path): - conf_d = tmp_path / "conf.d" - conf_d.mkdir() - (conf_d / "remotes.yaml").write_text(yaml.dump({"remotes": {"repo-extra": _remote("https://extra.com")}})) - main = tmp_path / "config.yaml" - main.write_text(yaml.dump({"config_dir": str(conf_d), "remotes": {"repo-main": _remote()}})) - cfg = ConfigManager(str(main)) - assert "repo-main" in cfg.config["remotes"] - assert "repo-extra" in cfg.config["remotes"] - - def test_relative_config_dir_resolved_from_main_file(self, tmp_path): - conf_d = tmp_path / "conf.d" - conf_d.mkdir() - (conf_d / "r.yaml").write_text(yaml.dump({"remotes": {"repo-a": _remote()}})) - main = tmp_path / "config.yaml" - main.write_text(yaml.dump({"config_dir": "conf.d", "remotes": {}})) - cfg = ConfigManager(str(main)) - assert "repo-a" in cfg.config["remotes"] - - def test_config_dir_key_not_present_in_loaded_config(self, tmp_path): - conf_d = tmp_path / "conf.d" - conf_d.mkdir() - main = tmp_path / "config.yaml" - main.write_text(yaml.dump({"config_dir": str(conf_d), "remotes": {}})) - cfg = ConfigManager(str(main)) - assert "config_dir" not in cfg.config - - def test_dir_remote_overrides_main_file_remote(self, tmp_path): - conf_d = tmp_path / "conf.d" - conf_d.mkdir() - (conf_d / "override.yaml").write_text(yaml.dump({"remotes": {"r": _remote("https://new.com")}})) - main = tmp_path / "config.yaml" - main.write_text(yaml.dump({"config_dir": str(conf_d), "remotes": {"r": _remote("https://old.com")}})) - cfg = ConfigManager(str(main)) - assert cfg.config["remotes"]["r"]["base_url"] == "https://new.com" - - def test_empty_config_dir_uses_main_file_only(self, tmp_path): - conf_d = tmp_path / "conf.d" - conf_d.mkdir() - main = tmp_path / "config.yaml" - main.write_text(yaml.dump({"config_dir": str(conf_d), "remotes": {"repo-main": _remote()}})) - cfg = ConfigManager(str(main)) - assert list(cfg.config["remotes"].keys()) == ["repo-main"] - - def test_reload_picks_up_changed_dir_file(self, tmp_path): - conf_d = tmp_path / "conf.d" - conf_d.mkdir() - dir_file = conf_d / "r.yaml" - dir_file.write_text(yaml.dump({"remotes": {"repo-v1": _remote()}})) - main = tmp_path / "config.yaml" - main.write_text(yaml.dump({"config_dir": str(conf_d), "remotes": {}})) - cfg = ConfigManager(str(main)) - assert "repo-v1" in cfg.config["remotes"] - - dir_file.write_text(yaml.dump({"remotes": {"repo-v2": _remote("https://v2.com")}})) - future_mtime = cfg._last_modified + 1 - os.utime(str(dir_file), (future_mtime, future_mtime)) - - cfg._check_reload() - - assert "repo-v2" in cfg.config["remotes"] - assert "repo-v1" not in cfg.config["remotes"] diff --git a/tests/test_docker_auth.py b/tests/test_docker_auth.py deleted file mode 100644 index 77bf48d..0000000 --- a/tests/test_docker_auth.py +++ /dev/null @@ -1,273 +0,0 @@ -"""Tests for docker_auth: WWW-Authenticate parsing and token caching.""" - -import time -from unittest.mock import AsyncMock, MagicMock, patch - -import httpx -import pytest - -from artifactapi import docker_auth -from artifactapi.docker_auth import ( - _cache_key, - _get_cached_token, - _store_token, - fetch_token, - get_docker_token_for_response, - parse_www_authenticate, -) - - -@pytest.fixture(autouse=True) -def clear_token_cache(): - """Isolate tests: wipe the module-level token cache before and after each test.""" - docker_auth._token_cache.clear() - yield - docker_auth._token_cache.clear() - - -# --------------------------------------------------------------------------- -# parse_www_authenticate -# --------------------------------------------------------------------------- - - -class TestParseWwwAuthenticate: - def test_full_bearer_header(self): - header = 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/nginx:pull"' - result = parse_www_authenticate(header) - assert result is not None - realm, service, scope = result - assert realm == "https://auth.docker.io/token" - assert service == "registry.docker.io" - assert scope == "repository:library/nginx:pull" - - def test_realm_only(self): - header = 'Bearer realm="https://auth.example.com/token"' - result = parse_www_authenticate(header) - assert result is not None - realm, service, scope = result - assert realm == "https://auth.example.com/token" - assert service == "" - assert scope == "" - - def test_realm_and_service_only(self): - header = 'Bearer realm="https://auth.example.com",service="registry.example.com"' - result = parse_www_authenticate(header) - assert result is not None - _, service, scope = result - assert service == "registry.example.com" - assert scope == "" - - def test_invalid_scheme_returns_none(self): - assert parse_www_authenticate('Basic realm="example"') is None - - def test_empty_header_returns_none(self): - assert parse_www_authenticate("") is None - - def test_case_insensitive_bearer_parses_realm(self): - header = 'bearer realm="https://auth.example.com/token"' - result = parse_www_authenticate(header) - assert result is not None - realm, _, _ = result - assert realm == "https://auth.example.com/token" - - def test_field_order_scope_before_service_drops_service(self): - # The regex requires realm,service,scope order; scope before service - # results in service being silently dropped. This test documents the known limitation. - header = 'Bearer realm="https://auth.example.com",scope="repo:pull",service="svc"' - result = parse_www_authenticate(header) - assert result is not None - realm, service, scope = result - assert realm == "https://auth.example.com" - assert scope == "repo:pull" - assert service == "" # silently dropped when out of order - - -# --------------------------------------------------------------------------- -# _cache_key -# --------------------------------------------------------------------------- - - -class TestCacheKey: - def test_key_contains_all_components(self): - key = _cache_key("https://realm.com", "svc", "scope", "user") - assert "https://realm.com" in key - assert "svc" in key - assert "scope" in key - assert "user" in key - - def test_none_username_uses_empty_string(self): - key = _cache_key("https://realm.com", "svc", "scope", None) - assert key.endswith("|") - - def test_different_services_give_different_keys(self): - k1 = _cache_key("realm", "svc1", "scope", None) - k2 = _cache_key("realm", "svc2", "scope", None) - assert k1 != k2 - - def test_different_scopes_give_different_keys(self): - k1 = _cache_key("realm", "svc", "scope:read", None) - k2 = _cache_key("realm", "svc", "scope:write", None) - assert k1 != k2 - - def test_pipe_in_field_value_can_collide_with_adjacent_fields(self): - # The "|" separator is not escaped, so a pipe embedded in one field - # produces the same key as the same pipe appearing as a separator boundary. - # This is a known limitation: _cache_key("a|b","c","d",None) == - # _cache_key("a","b|c","d",None). Documents the behaviour, not a claim it's correct. - k1 = _cache_key("a|b", "c", "d", None) - k2 = _cache_key("a", "b|c", "d", None) - assert k1 == k2 - - -# --------------------------------------------------------------------------- -# _get_cached_token / _store_token -# --------------------------------------------------------------------------- - - -class TestTokenCaching: - def test_get_returns_none_when_not_cached(self): - assert _get_cached_token("no-such-key") is None - - def test_get_returns_token_when_valid(self): - _store_token("mykey", "tok-abc", 300) - assert _get_cached_token("mykey") == "tok-abc" - - def test_get_returns_none_when_expired(self): - docker_auth._token_cache["mykey"] = ("old-token", time.time() - 1) - assert _get_cached_token("mykey") is None - - def test_expired_entry_is_removed_from_cache(self): - docker_auth._token_cache["mykey"] = ("old-token", time.time() - 1) - _get_cached_token("mykey") - assert "mykey" not in docker_auth._token_cache - - def test_store_expires_30s_before_stated_time(self): - before = time.time() - _store_token("mykey", "tok", 100) - _, expires_at = docker_auth._token_cache["mykey"] - # expires_in - 30 = 70; allow ±2 s clock wiggle - assert before + 68 <= expires_at <= before + 72 - - def test_store_enforces_minimum_10s_expiry(self): - before = time.time() - _store_token("mykey", "tok", 5) # expires_in - 30 would be negative - _, expires_at = docker_auth._token_cache["mykey"] - assert expires_at >= before + 10 - - -# --------------------------------------------------------------------------- -# fetch_token (async, mocks httpx) -# --------------------------------------------------------------------------- - - -def _make_mock_http_client(token_payload: dict): - mock_response = MagicMock() - mock_response.raise_for_status = MagicMock() - mock_response.json.return_value = token_payload - - mock_client = AsyncMock() - mock_client.get = AsyncMock(return_value=mock_response) - - ctx = MagicMock() - ctx.__aenter__ = AsyncMock(return_value=mock_client) - ctx.__aexit__ = AsyncMock(return_value=False) - return ctx, mock_client - - -class TestFetchToken: - async def test_returns_token_field(self): - ctx, _ = _make_mock_http_client({"token": "bearer-tok", "expires_in": 300}) - with patch("httpx.AsyncClient", return_value=ctx): - token = await fetch_token("https://auth.example.com", "svc", "scope") - assert token == "bearer-tok" - - async def test_falls_back_to_access_token_field(self): - ctx, _ = _make_mock_http_client({"access_token": "access-tok", "expires_in": 300}) - with patch("httpx.AsyncClient", return_value=ctx): - token = await fetch_token("https://auth.example.com", "svc", "scope") - assert token == "access-tok" - - async def test_returns_none_when_response_missing_token_field(self): - ctx, _ = _make_mock_http_client({"not_token": "value", "expires_in": 300}) - with patch("httpx.AsyncClient", return_value=ctx): - token = await fetch_token("https://auth.example.com", "svc", "scope") - assert token is None - - async def test_defaults_expires_in_to_300_when_missing(self): - ctx, _ = _make_mock_http_client({"token": "tok"}) # no expires_in key - before = time.time() - with patch("httpx.AsyncClient", return_value=ctx): - token = await fetch_token("https://auth.example.com", "svc", "scope") - assert token == "tok" - key = _cache_key("https://auth.example.com", "svc", "scope", None) - _, expires_at = docker_auth._token_cache[key] - # Default expires_in=300, stored as time.time() + max(300-30, 10) = 270 - assert before + 268 <= expires_at <= before + 272 - - async def test_uses_cache_on_second_call_without_http(self): - ctx, mock_client = _make_mock_http_client({"token": "cached-tok", "expires_in": 300}) - with patch("httpx.AsyncClient", return_value=ctx): - await fetch_token("https://auth.example.com", "svc", "scope") - mock_client.get.reset_mock() - token = await fetch_token("https://auth.example.com", "svc", "scope") - mock_client.get.assert_not_called() - assert token == "cached-tok" - - async def test_returns_none_on_network_error(self): - mock_client = AsyncMock() - mock_client.get = AsyncMock(side_effect=Exception("connection refused")) - ctx = MagicMock() - ctx.__aenter__ = AsyncMock(return_value=mock_client) - ctx.__aexit__ = AsyncMock(return_value=False) - with patch("httpx.AsyncClient", return_value=ctx): - token = await fetch_token("https://auth.example.com", "svc", "scope") - assert token is None - - async def test_returns_none_on_http_status_error(self): - mock_response = MagicMock() - mock_response.raise_for_status.side_effect = httpx.HTTPStatusError("401 Unauthorized", request=MagicMock(), response=MagicMock()) - mock_client = AsyncMock() - mock_client.get = AsyncMock(return_value=mock_response) - ctx = MagicMock() - ctx.__aenter__ = AsyncMock(return_value=mock_client) - ctx.__aexit__ = AsyncMock(return_value=False) - with patch("httpx.AsyncClient", return_value=ctx): - token = await fetch_token("https://auth.example.com", "svc", "scope") - assert token is None - - async def test_passes_credentials_as_auth_tuple(self): - ctx, mock_client = _make_mock_http_client({"token": "authed-tok", "expires_in": 300}) - with patch("httpx.AsyncClient", return_value=ctx): - await fetch_token("https://auth.example.com", "svc", "scope", "user", "pass") - call_kwargs = mock_client.get.call_args.kwargs - assert call_kwargs.get("auth") == ("user", "pass") - - async def test_no_auth_when_no_credentials(self): - ctx, mock_client = _make_mock_http_client({"token": "anon-tok", "expires_in": 300}) - with patch("httpx.AsyncClient", return_value=ctx): - await fetch_token("https://auth.example.com", "svc", "scope") - call_kwargs = mock_client.get.call_args.kwargs - assert call_kwargs.get("auth") is None - - -# --------------------------------------------------------------------------- -# get_docker_token_for_response -# --------------------------------------------------------------------------- - - -class TestGetDockerTokenForResponse: - async def test_returns_none_for_non_bearer_header(self): - token = await get_docker_token_for_response('Basic realm="example"') - assert token is None - - async def test_end_to_end_parse_and_fetch(self): - """parse_www_authenticate → fetch_token wired together end-to-end.""" - header = 'Bearer realm="https://auth.example.com",service="svc",scope="repo:pull"' - ctx, mock_client = _make_mock_http_client({"token": "e2e-tok", "expires_in": 300}) - with patch("httpx.AsyncClient", return_value=ctx): - token = await get_docker_token_for_response(header, "user", "pass") - assert token == "e2e-tok" - call_kwargs = mock_client.get.call_args.kwargs - assert call_kwargs["params"]["service"] == "svc" - assert call_kwargs["params"]["scope"] == "repo:pull" - assert call_kwargs["auth"] == ("user", "pass") diff --git a/tests/test_routes.py b/tests/test_routes.py deleted file mode 100644 index 6195e59..0000000 --- a/tests/test_routes.py +++ /dev/null @@ -1,1528 +0,0 @@ -"""FastAPI route tests using TestClient with mocked service dependencies.""" - -import hashlib -import json -from datetime import UTC -from unittest.mock import ANY, AsyncMock, MagicMock, patch - -import pytest - -# --------------------------------------------------------------------------- -# Per-test service mocks (replace module-level globals in main.py) -# --------------------------------------------------------------------------- - - -@pytest.fixture -def mock_storage(): - m = MagicMock() - m.get_object_key.return_value = "test-remote/abc123/file.ext" - m.exists.return_value = False - m.download_object.return_value = b"fake content" - m.bucket = "testbucket" - m.client = MagicMock() - return m - - -@pytest.fixture -def mock_cache(): - m = MagicMock() - m.is_mutable_file.return_value = False - m.is_index_valid.return_value = True - m.available = False - m.client = None - return m - - -@pytest.fixture -def mock_database(): - m = MagicMock() - m.available = False - return m - - -@pytest.fixture -def mock_metrics(): - return MagicMock() - - -@pytest.fixture -def patched_deps(mock_storage, mock_cache, mock_database, mock_metrics): - """Swap the module-level service instances in main.py for the duration of a test.""" - import artifactapi.main as main_mod - - with ( - patch.object(main_mod, "storage", mock_storage), - patch.object(main_mod, "cache", mock_cache), - patch.object(main_mod, "database", mock_database), - patch.object(main_mod, "metrics", mock_metrics), - ): - yield { - "storage": mock_storage, - "cache": mock_cache, - "database": mock_database, - "metrics": mock_metrics, - } - - -# --------------------------------------------------------------------------- -# Basic / health endpoints -# --------------------------------------------------------------------------- - - -class TestBasicEndpoints: - def test_root_returns_remote_list(self, client): - response = client.get("/") - assert response.status_code == 200 - data = response.json() - assert "remotes" in data - assert isinstance(data["remotes"], list) - assert len(data["remotes"]) > 0 - - def test_root_contains_version(self, client): - response = client.get("/") - assert "version" in response.json() - - def test_health_check(self, client): - response = client.get("/health") - assert response.status_code == 200 - assert response.json()["status"] == "healthy" - - def test_docker_v2_ping(self, client): - response = client.get("/v2/") - assert response.status_code == 200 - assert response.headers.get("Docker-Distribution-Api-Version") == "registry/2.0" - assert response.json() == {} - - -# --------------------------------------------------------------------------- -# Docker proxy /v2/{remote}/{path} -# --------------------------------------------------------------------------- - - -class TestDockerProxy: - def test_unknown_remote_returns_404(self, client, patched_deps): - response = client.get("/v2/no-such-remote/library/nginx/manifests/latest") - assert response.status_code == 404 - - def test_non_docker_package_returns_400(self, client, patched_deps): - # alpine-test is package: alpine, not docker - response = client.get("/v2/alpine-test/library/nginx/manifests/latest") - assert response.status_code == 400 - - def test_pattern_blocked_returns_403(self, client, patched_deps): - # docker-restricted allows only "library/nginx" - response = client.get("/v2/docker-restricted/library/ubuntu/manifests/latest") - assert response.status_code == 403 - - def test_allowed_pattern_proceeds_to_cache(self, client, patched_deps): - deps = patched_deps - manifest = json.dumps( - { - "mediaType": "application/vnd.oci.image.manifest.v1+json", - "layers": [], - } - ).encode() - deps["storage"].exists.return_value = True - deps["storage"].download_object.return_value = manifest - deps["cache"].is_mutable_file.return_value = True - deps["cache"].is_index_valid.return_value = True - - response = client.get("/v2/docker-restricted/library/nginx/manifests/latest") - assert response.status_code == 200 - - def test_cache_hit_manifest_returns_correct_content_type(self, client, patched_deps): - deps = patched_deps - manifest = json.dumps( - { - "mediaType": "application/vnd.docker.distribution.manifest.v2+json", - "schemaVersion": 2, - "layers": [], - } - ).encode() - deps["storage"].exists.return_value = True - deps["storage"].download_object.return_value = manifest - deps["cache"].is_mutable_file.return_value = True - deps["cache"].is_index_valid.return_value = True - - response = client.get("/v2/docker-test/library/nginx/manifests/latest") - assert response.status_code == 200 - ct = response.headers["content-type"] - assert ct.startswith("application/vnd.docker.distribution.manifest.v2+json") - - def test_cache_hit_sets_docker_content_digest_header(self, client, patched_deps): - deps = patched_deps - manifest = json.dumps( - { - "mediaType": "application/vnd.oci.image.manifest.v1+json", - "layers": [], - } - ).encode() - deps["storage"].exists.return_value = True - deps["storage"].download_object.return_value = manifest - deps["cache"].is_mutable_file.return_value = True - deps["cache"].is_index_valid.return_value = True - - response = client.get("/v2/docker-test/library/nginx/manifests/latest") - expected = f"sha256:{hashlib.sha256(manifest).hexdigest()}" - assert response.headers["Docker-Content-Digest"] == expected - - def test_cache_hit_records_metrics(self, client, patched_deps): - deps = patched_deps - manifest = json.dumps({"mediaType": "application/vnd.oci.image.manifest.v1+json", "layers": []}).encode() - deps["storage"].exists.return_value = True - deps["storage"].download_object.return_value = manifest - deps["cache"].is_mutable_file.return_value = False - - client.get("/v2/docker-test/library/nginx/manifests/latest") - deps["metrics"].record_cache_hit.assert_called_once_with("docker-test", ANY) - - def test_head_request_returns_no_body(self, client, patched_deps): - deps = patched_deps - manifest = json.dumps( - { - "mediaType": "application/vnd.oci.image.manifest.v1+json", - "layers": [], - } - ).encode() - deps["storage"].exists.return_value = True - deps["storage"].download_object.return_value = manifest - deps["cache"].is_mutable_file.return_value = False - - response = client.head("/v2/docker-test/library/nginx/manifests/latest") - assert response.status_code == 200 - assert response.content == b"" - - def test_cache_miss_calls_upstream_fetch(self, client, patched_deps): - deps = patched_deps - manifest = json.dumps( - { - "mediaType": "application/vnd.oci.image.manifest.v1+json", - "layers": [], - } - ).encode() - deps["storage"].exists.return_value = False - deps["storage"].download_object.return_value = manifest - deps["cache"].is_mutable_file.return_value = True - - with patch( - "artifactapi.artifact.proxy.cache_single_artifact", - new_callable=AsyncMock, - return_value={"status": "cached"}, - ) as mock_fetch: - response = client.get("/v2/docker-test/library/nginx/manifests/latest") - - mock_fetch.assert_called_once() - assert response.status_code == 200 - - def test_cache_miss_on_index_marks_index_cached(self, client, patched_deps): - deps = patched_deps - manifest = json.dumps( - { - "mediaType": "application/vnd.oci.image.manifest.v1+json", - "layers": [], - } - ).encode() - deps["storage"].exists.return_value = False - deps["storage"].download_object.return_value = manifest - deps["cache"].is_mutable_file.return_value = True - - with patch( - "artifactapi.artifact.proxy.cache_single_artifact", - new_callable=AsyncMock, - return_value={"status": "cached"}, - ): - response = client.get("/v2/docker-test/library/nginx/manifests/latest") - - assert response.status_code == 200 - deps["cache"].mark_index_cached.assert_called_once() - - def test_index_expired_triggers_refetch(self, client, patched_deps): - deps = patched_deps - manifest = json.dumps( - { - "mediaType": "application/vnd.oci.image.manifest.v1+json", - "layers": [], - } - ).encode() - deps["storage"].exists.return_value = True # cached in S3 - deps["cache"].is_mutable_file.return_value = True - deps["cache"].is_index_valid.return_value = False # but TTL expired - deps["storage"].download_object.return_value = manifest - - with patch("artifactapi.artifact.proxy._upstream_reachable", new_callable=AsyncMock, return_value=True): - with patch( - "artifactapi.artifact.proxy.cache_single_artifact", - new_callable=AsyncMock, - return_value={"status": "cached"}, - ) as mock_fetch: - response = client.get("/v2/docker-test/library/nginx/manifests/latest") - - mock_fetch.assert_called_once() - assert response.status_code == 200 - - # --- Issue 1: sha256 digest cross-linking --- - - def test_tag_manifest_is_stored_under_digest_key_on_cache_hit(self, client, patched_deps): - # When serving a cached tag manifest the handler must also write the content - # under the sha256 digest key so subsequent sha256-addressed pulls hit cache. - deps = patched_deps - manifest = json.dumps({"mediaType": "application/vnd.oci.image.manifest.v1+json", "layers": []}).encode() - # First exists call (tag manifest): hit. Second (digest key): miss → triggers upload. - deps["storage"].exists.side_effect = [True, False] - deps["storage"].download_object.return_value = manifest - deps["cache"].is_mutable_file.return_value = True - deps["cache"].is_index_valid.return_value = True - - response = client.get("/v2/docker-test/library/nginx/manifests/v1.25.3") - - assert response.status_code == 200 - deps["storage"].upload.assert_called_once_with(deps["storage"].get_object_key.return_value, manifest) - - def test_tag_manifest_digest_key_not_written_when_already_exists(self, client, patched_deps): - # When the digest key already exists in storage upload must not be called. - deps = patched_deps - manifest = json.dumps({"mediaType": "application/vnd.oci.image.manifest.v1+json", "layers": []}).encode() - # Both the tag key and the digest key already present. - deps["storage"].exists.return_value = True - deps["storage"].download_object.return_value = manifest - deps["cache"].is_mutable_file.return_value = True - deps["cache"].is_index_valid.return_value = True - - client.get("/v2/docker-test/library/nginx/manifests/v1.25.3") - - deps["storage"].upload.assert_not_called() - - def test_sha256_manifest_request_is_not_cross_linked(self, client, patched_deps): - # sha256-addressed manifests are immutable — the cross-link logic must not apply. - deps = patched_deps - manifest = json.dumps({"mediaType": "application/vnd.oci.image.manifest.v1+json", "layers": []}).encode() - deps["storage"].exists.return_value = True - deps["storage"].download_object.return_value = manifest - deps["cache"].is_mutable_file.return_value = False # sha256 manifest is immutable - - with patch("artifactapi.artifact.proxy._fetch_last_modified", new_callable=AsyncMock, return_value=None): - client.get("/v2/docker-test/library/nginx/manifests/sha256:" + "a" * 64) - - deps["storage"].upload.assert_not_called() - - # --- Issue 2: thundering herd distributed lock --- - - def test_lock_acquired_and_released_on_upstream_fetch(self, client, patched_deps): - deps = patched_deps - manifest = json.dumps({"mediaType": "application/vnd.oci.image.manifest.v1+json", "layers": []}).encode() - deps["storage"].exists.side_effect = [False, False] # initial miss; digest key also absent - deps["storage"].download_object.return_value = manifest - deps["cache"].is_mutable_file.return_value = True - deps["cache"].acquire_fetch_lock.return_value = True - - with patch( - "artifactapi.artifact.proxy.cache_single_artifact", - new_callable=AsyncMock, - return_value={"status": "cached"}, - ): - response = client.get("/v2/docker-test/library/nginx/manifests/latest") - - deps["cache"].acquire_fetch_lock.assert_called_once() - deps["cache"].release_fetch_lock.assert_called_once() - assert response.status_code == 200 - - def test_lock_released_even_when_fetch_returns_error(self, client, patched_deps): - deps = patched_deps - deps["storage"].exists.return_value = False - deps["cache"].is_mutable_file.return_value = True - deps["cache"].acquire_fetch_lock.return_value = True - - with patch( - "artifactapi.artifact.proxy.cache_single_artifact", - new_callable=AsyncMock, - return_value={"status": "error", "error": "upstream down"}, - ): - response = client.get("/v2/docker-test/library/nginx/manifests/latest") - - deps["cache"].release_fetch_lock.assert_called_once() - assert response.status_code == 502 - - def test_thundering_herd_polls_storage_when_lock_not_acquired(self, client, patched_deps): - # When the lock is held by another pod the handler must poll storage and serve - # from cache once the competing fetch completes, without issuing its own upstream request. - deps = patched_deps - manifest = json.dumps({"mediaType": "application/vnd.oci.image.manifest.v1+json", "layers": []}).encode() - # Initial cache check: miss. First poll iteration: another pod has written it. - # Third call is for the digest cross-link check (is_mutable=True path); digest key exists. - deps["storage"].exists.side_effect = [False, True, True] - deps["storage"].download_object.return_value = manifest - deps["cache"].is_mutable_file.return_value = True - deps["cache"].is_index_valid.return_value = True - deps["cache"].acquire_fetch_lock.return_value = False # lock held by peer - - with patch("artifactapi.artifact.docker.asyncio.sleep", new_callable=AsyncMock): - with patch( - "artifactapi.artifact.proxy.cache_single_artifact", - new_callable=AsyncMock, - ) as mock_fetch: - response = client.get("/v2/docker-test/library/nginx/manifests/latest") - - mock_fetch.assert_not_called() - assert response.status_code == 200 - - def test_thundering_herd_falls_through_to_fetch_if_poll_times_out(self, client, patched_deps): - # If the item never appears in storage during the poll window the handler must - # still issue its own upstream fetch as a fallback. - deps = patched_deps - manifest = json.dumps({"mediaType": "application/vnd.oci.image.manifest.v1+json", "layers": []}).encode() - # All exists calls return False — item never appears during polling. - deps["storage"].exists.return_value = False - deps["storage"].download_object.return_value = manifest - deps["cache"].is_mutable_file.return_value = True - deps["cache"].acquire_fetch_lock.return_value = False # lock held by peer - - with patch("artifactapi.artifact.docker.asyncio.sleep", new_callable=AsyncMock): - with patch( - "artifactapi.artifact.proxy.cache_single_artifact", - new_callable=AsyncMock, - return_value={"status": "cached"}, - ) as mock_fetch: - response = client.get("/v2/docker-test/library/nginx/manifests/latest") - - mock_fetch.assert_called_once() - assert response.status_code == 200 - - -# --------------------------------------------------------------------------- -# Docker ban_tags feature -# --------------------------------------------------------------------------- - - -class TestDockerBanTags: - def test_banned_tag_returns_403(self, client, patched_deps): - response = client.get("/v2/docker-bantags-test/library/nginx/manifests/latest") - assert response.status_code == 403 - assert "latest" in response.json()["detail"] - - def test_second_banned_tag_returns_403(self, client, patched_deps): - response = client.get("/v2/docker-bantags-test/library/nginx/manifests/edge") - assert response.status_code == 403 - assert "edge" in response.json()["detail"] - - def test_allowed_tag_proceeds(self, client, patched_deps): - deps = patched_deps - manifest = json.dumps({"mediaType": "application/vnd.oci.image.manifest.v1+json", "layers": []}).encode() - deps["storage"].exists.return_value = True - deps["storage"].download_object.return_value = manifest - deps["cache"].is_mutable_file.return_value = True - deps["cache"].is_index_valid.return_value = True - - response = client.get("/v2/docker-bantags-test/library/nginx/manifests/1.25.3") - assert response.status_code == 200 - - def test_digest_pull_bypasses_ban(self, client, patched_deps): - # sha256-addressed pulls must never be blocked by the tag ban list - deps = patched_deps - manifest = json.dumps({"mediaType": "application/vnd.oci.image.manifest.v1+json", "layers": []}).encode() - deps["storage"].exists.return_value = True - deps["storage"].download_object.return_value = manifest - deps["cache"].is_mutable_file.return_value = False - - digest = "sha256:" + "a" * 64 - with patch("artifactapi.artifact.proxy._fetch_last_modified", new_callable=AsyncMock, return_value=None): - response = client.get(f"/v2/docker-bantags-test/library/nginx/manifests/{digest}") - assert response.status_code == 200 - - def test_ban_tags_disabled_by_default(self, client, patched_deps): - # docker-test has no ban_tags_enabled — "latest" must pass through - deps = patched_deps - manifest = json.dumps({"mediaType": "application/vnd.oci.image.manifest.v1+json", "layers": []}).encode() - deps["storage"].exists.return_value = True - deps["storage"].download_object.return_value = manifest - deps["cache"].is_mutable_file.return_value = True - deps["cache"].is_index_valid.return_value = True - - response = client.get("/v2/docker-test/library/nginx/manifests/latest") - assert response.status_code == 200 - - def test_ban_tags_enabled_but_empty_list_allows_all(self, client, patched_deps): - # If ban_tags_enabled is true but ban_tags is empty nothing should be blocked. - # docker-test doesn't have ban_tags_enabled, but we can verify via the - # docker-bantags-test remote with an unlisted tag. - deps = patched_deps - manifest = json.dumps({"mediaType": "application/vnd.oci.image.manifest.v1+json", "layers": []}).encode() - deps["storage"].exists.return_value = True - deps["storage"].download_object.return_value = manifest - deps["cache"].is_mutable_file.return_value = True - deps["cache"].is_index_valid.return_value = True - - response = client.get("/v2/docker-bantags-test/library/nginx/manifests/stable") - assert response.status_code == 200 - - def test_ban_check_does_not_apply_to_blobs(self, client, patched_deps): - # Blob paths don't contain /manifests/ — the ban check must not interfere - deps = patched_deps - deps["storage"].exists.return_value = True - deps["storage"].download_object.return_value = b"\x00" * 100 - deps["cache"].is_mutable_file.return_value = False - - with patch("artifactapi.artifact.proxy._fetch_last_modified", new_callable=AsyncMock, return_value=None): - response = client.get("/v2/docker-bantags-test/library/nginx/blobs/sha256:" + "b" * 64) - assert response.status_code == 200 - - -# --------------------------------------------------------------------------- -# Generic artifact route /api/v1/remote/{remote}/{path} -# --------------------------------------------------------------------------- - - -class TestGenericArtifactRoute: - def test_unknown_remote_returns_404(self, client, patched_deps): - response = client.get("/api/v1/remote/nonexistent/path/to/file.tar.gz") - assert response.status_code == 404 - - def test_pattern_blocked_returns_403(self, client, patched_deps): - # generic-test only allows .tar.gz - response = client.get("/api/v1/remote/generic-test/some/path/file.rpm") - assert response.status_code == 403 - - def test_cache_hit_returns_200_with_source_header(self, client, patched_deps): - deps = patched_deps - deps["storage"].exists.return_value = True - deps["storage"].download_object.return_value = b"tar content" - deps["cache"].is_mutable_file.return_value = False - - response = client.get("/api/v1/remote/generic-test/some/path/archive.tar.gz") - assert response.status_code == 200 - assert response.headers["X-Artifact-Source"] == "cache" - assert response.content == b"tar content" - - def test_cache_hit_sets_content_disposition(self, client, patched_deps): - deps = patched_deps - deps["storage"].exists.return_value = True - deps["storage"].download_object.return_value = b"content" - deps["cache"].is_mutable_file.return_value = False - - response = client.get("/api/v1/remote/generic-test/some/path/archive.tar.gz") - disposition = response.headers["content-disposition"] - assert "attachment" in disposition - assert "archive.tar.gz" in disposition - - def test_cache_hit_sets_artifact_size_header(self, client, patched_deps): - deps = patched_deps - content = b"some artifact content bytes" - deps["storage"].exists.return_value = True - deps["storage"].download_object.return_value = content - deps["cache"].is_mutable_file.return_value = False - - response = client.get("/api/v1/remote/generic-test/some/path/archive.tar.gz") - assert response.headers["X-Artifact-Size"] == str(len(content)) - - def test_cache_hit_records_metrics(self, client, patched_deps): - deps = patched_deps - deps["storage"].exists.return_value = True - deps["storage"].download_object.return_value = b"content" - deps["cache"].is_mutable_file.return_value = False - - client.get("/api/v1/remote/generic-test/some/path/archive.tar.gz") - deps["metrics"].record_cache_hit.assert_called_once_with("generic-test", ANY) - - def test_cache_hit_records_artifact_mapping(self, client, patched_deps): - deps = patched_deps - deps["storage"].exists.return_value = True - deps["storage"].download_object.return_value = b"content" - deps["cache"].is_mutable_file.return_value = False - - client.get("/api/v1/remote/generic-test/some/path/archive.tar.gz") - deps["database"].record_artifact_mapping.assert_called_once() - - def test_cache_hit_rpm_returns_correct_content_type(self, client, patched_deps): - deps = patched_deps - deps["storage"].exists.return_value = True - deps["storage"].download_object.return_value = b"rpm bytes" - deps["cache"].is_mutable_file.return_value = False - - response = client.get("/api/v1/remote/rpm-test/almalinux/9/x86_64/bash-5.1.8.x86_64.rpm") - assert response.status_code == 200 - assert "application/x-rpm" in response.headers["content-type"] - - def test_cache_hit_xml_returns_correct_content_type(self, client, patched_deps): - deps = patched_deps - deps["storage"].exists.return_value = True - deps["storage"].download_object.return_value = b"" - deps["cache"].is_mutable_file.return_value = False - - response = client.get("/api/v1/remote/rpm-test/repo/repodata/primary.xml") - assert response.status_code == 200 - assert "application/xml" in response.headers["content-type"] - - def test_cache_miss_fetches_upstream_and_returns_200(self, client, patched_deps): - deps = patched_deps - deps["storage"].exists.return_value = False - deps["storage"].download_object.return_value = b"fresh content" - deps["cache"].is_mutable_file.return_value = False - - with patch( - "artifactapi.artifact.proxy.cache_single_artifact", - new_callable=AsyncMock, - return_value={"status": "cached"}, - ) as mock_fetch: - response = client.get("/api/v1/remote/generic-test/some/path/archive.tar.gz") - - mock_fetch.assert_called_once() - assert response.status_code == 200 - assert response.headers["X-Artifact-Source"] == "remote" - - def test_cache_miss_records_metrics(self, client, patched_deps): - deps = patched_deps - deps["storage"].exists.return_value = False - deps["storage"].download_object.return_value = b"fresh content" - deps["cache"].is_mutable_file.return_value = False - - with patch( - "artifactapi.artifact.proxy.cache_single_artifact", - new_callable=AsyncMock, - return_value={"status": "cached"}, - ): - client.get("/api/v1/remote/generic-test/some/path/archive.tar.gz") - - deps["metrics"].record_cache_miss.assert_called_once_with("generic-test", ANY) - - def test_cache_miss_on_index_marks_index_cached(self, client, patched_deps): - deps = patched_deps - deps["storage"].exists.return_value = False - deps["storage"].download_object.return_value = b"APKINDEX content" - deps["cache"].is_mutable_file.return_value = True - - with patch( - "artifactapi.artifact.proxy.cache_single_artifact", - new_callable=AsyncMock, - return_value={"status": "cached"}, - ): - response = client.get("/api/v1/remote/alpine-test/alpine/v3.18/x86_64/APKINDEX.tar.gz") - - assert response.status_code == 200 - deps["cache"].mark_index_cached.assert_called_once() - - def test_upstream_error_returns_502(self, client, patched_deps): - deps = patched_deps - deps["storage"].exists.return_value = False - deps["cache"].is_mutable_file.return_value = False - - with patch( - "artifactapi.artifact.proxy.cache_single_artifact", - new_callable=AsyncMock, - return_value={"status": "error", "error": "upstream unreachable"}, - ): - response = client.get("/api/v1/remote/generic-test/some/path/archive.tar.gz") - - assert response.status_code == 502 - - def test_mutable_file_bypasses_immutable_patterns(self, client, patched_deps): - """Mutable files must be served even when they don't match immutable_patterns.""" - deps = patched_deps - deps["storage"].exists.return_value = True - deps["storage"].download_object.return_value = b"APKINDEX content" - deps["cache"].is_mutable_file.return_value = True - deps["cache"].is_index_valid.return_value = True - - # APKINDEX.tar.gz does not match alpine-test's immutable_patterns (.*.apk$), - # but since is_mutable_file returns True it must be allowed through. - response = client.get("/api/v1/remote/alpine-test/alpine/v3.18/x86_64/APKINDEX.tar.gz") - assert response.status_code == 200 - - def test_mutable_unchanged_refreshes_ttl_without_redownload(self, client, patched_deps): - """When check_mutable_updates=True and upstream says 304, TTL is refreshed in place.""" - deps = patched_deps - deps["storage"].exists.return_value = True - deps["storage"].download_object.return_value = b"metadata content" - # File is mutable and its TTL has expired - deps["cache"].is_mutable_file.return_value = True - deps["cache"].is_index_valid.return_value = False - deps["cache"].get_mutable_meta.return_value = {"etag": '"abc"'} - - with patch("artifactapi.artifact.proxy.check_upstream_changed", new_callable=AsyncMock, return_value=False): - response = client.get("/api/v1/remote/check-mutable-test/metadata.json") - - assert response.status_code == 200 - deps["cache"].mark_index_cached.assert_called() - # S3 object must NOT have been deleted (no re-download) - deps["storage"].client.delete_object.assert_not_called() - - def test_mutable_changed_triggers_redownload(self, client, patched_deps): - """When check_mutable_updates=True and upstream says 200, cache is invalidated.""" - deps = patched_deps - deps["storage"].exists.return_value = False - deps["cache"].is_mutable_file.return_value = True - deps["cache"].is_index_valid.return_value = False - deps["cache"].get_mutable_meta.return_value = {"etag": '"abc"'} - - with patch("artifactapi.artifact.proxy.check_upstream_changed", new_callable=AsyncMock, return_value=True): - with patch("artifactapi.artifact.proxy.cache_single_artifact", new_callable=AsyncMock) as mock_cache: - mock_cache.return_value = {"status": "error", "error": "upstream gone"} - response = client.get("/api/v1/remote/check-mutable-test/metadata.json") - - assert response.status_code == 502 - - def test_mutable_changed_redownloads_successfully(self, client, patched_deps): - """When check_mutable_updates=True and upstream says 200, fresh copy is fetched and served.""" - deps = patched_deps - deps["storage"].exists.return_value = True - deps["storage"].download_object.return_value = b"fresh metadata" - deps["cache"].is_mutable_file.return_value = True - deps["cache"].is_index_valid.return_value = False - deps["cache"].get_mutable_meta.return_value = {"etag": '"abc"'} - - with patch("artifactapi.artifact.proxy.check_upstream_changed", new_callable=AsyncMock, return_value=True): - with patch("artifactapi.artifact.proxy.cache_single_artifact", new_callable=AsyncMock) as mock_cache: - mock_cache.return_value = {"status": "cached", "etag": '"def"', "last_modified": None} - response = client.get("/api/v1/remote/check-mutable-test/metadata.json") - - assert response.status_code == 200 - mock_cache.assert_called_once() - - def test_mutable_backend_unreachable_on_check_updates_keeps_stale(self, client, patched_deps): - """When check_mutable_updates=True and backend is unreachable, stale copy is kept and TTL refreshed.""" - from artifactapi.artifact.proxy import UpstreamUnreachable - - deps = patched_deps - deps["storage"].exists.return_value = True - deps["storage"].download_object.return_value = b"stale metadata" - deps["cache"].is_mutable_file.return_value = True - deps["cache"].is_index_valid.return_value = False - deps["cache"].get_mutable_meta.return_value = {"etag": '"abc"'} - - with patch("artifactapi.artifact.proxy.check_upstream_changed", side_effect=UpstreamUnreachable("connection refused")): - response = client.get("/api/v1/remote/check-mutable-test/metadata.json") - - assert response.status_code == 200 - deps["cache"].mark_index_cached.assert_called() - deps["storage"].client.delete_object.assert_not_called() - - def test_mutable_backend_unreachable_on_expiry_keeps_stale(self, client, patched_deps): - """When a regular mutable file expires and backend is unreachable, stale copy is kept and TTL refreshed.""" - deps = patched_deps - deps["storage"].exists.return_value = True - deps["storage"].download_object.return_value = b"stale APKINDEX" - deps["cache"].is_mutable_file.return_value = True - deps["cache"].is_index_valid.return_value = False - - with patch("artifactapi.artifact.proxy._upstream_reachable", new_callable=AsyncMock, return_value=False): - response = client.get("/api/v1/remote/alpine-test/alpine/v3.18/x86_64/APKINDEX.tar.gz") - - assert response.status_code == 200 - deps["cache"].mark_index_cached.assert_called() - deps["storage"].client.delete_object.assert_not_called() - - def test_mutable_flag_off_skips_conditional_check(self, client, patched_deps): - """When check_mutable_updates is not set, expired mutable files are always re-fetched.""" - deps = patched_deps - deps["storage"].exists.return_value = False - deps["cache"].is_mutable_file.return_value = True - deps["cache"].is_index_valid.return_value = False - - with patch("artifactapi.artifact.proxy.check_upstream_changed", new_callable=AsyncMock) as mock_check: - with patch("artifactapi.artifact.proxy.cache_single_artifact", new_callable=AsyncMock) as mock_cache: - mock_cache.return_value = {"status": "error", "error": "upstream gone"} - client.get("/api/v1/remote/custom-index-test/metadata.json") - - mock_check.assert_not_called() - - def test_local_repo_file_not_found_returns_404(self, client, patched_deps): - deps = patched_deps - deps["database"].get_local_file_metadata.return_value = None - deps["database"].available = True - - response = client.get("/api/v1/local/local-test/path/to/nonexistent.bin") - assert response.status_code == 404 - - -# --------------------------------------------------------------------------- -# Upload route PUT /api/v1/local/{local}/{path} -# --------------------------------------------------------------------------- - - -class TestUploadRoute: - def test_unknown_local_returns_404(self, client, patched_deps): - response = client.put( - "/api/v1/local/nonexistent/path/to/file.tar.gz", - files={"file": ("file.tar.gz", b"content", "application/octet-stream")}, - ) - assert response.status_code == 404 - - -# --------------------------------------------------------------------------- -# HEAD route HEAD /api/v1/local/{local}/{path} -# --------------------------------------------------------------------------- - - -class TestHeadRoute: - def test_local_repo_file_not_found_returns_404(self, client, patched_deps): - deps = patched_deps - deps["database"].get_local_file_metadata.return_value = None - deps["database"].available = True - - response = client.head("/api/v1/local/local-test/path/to/nonexistent.bin") - assert response.status_code == 404 - - def test_unknown_local_returns_404(self, client, patched_deps): - response = client.head("/api/v1/local/nonexistent/path/to/file.bin") - assert response.status_code == 404 - - -# --------------------------------------------------------------------------- -# DELETE route DELETE /api/v1/local/{local}/{path} -# --------------------------------------------------------------------------- - - -class TestDeleteRoute: - def test_unknown_local_returns_404(self, client, patched_deps): - response = client.delete("/api/v1/local/nonexistent/path/to/file.tar.gz") - assert response.status_code == 404 - - -# --------------------------------------------------------------------------- -# Cache flush PUT /cache/flush -# --------------------------------------------------------------------------- - - -class TestCacheFlushEndpoint: - def test_flush_all_returns_flushed_structure(self, client, patched_deps): - deps = patched_deps - deps["cache"].available = False - deps["storage"].client.list_objects_v2.return_value = {} - - response = client.put("/cache/flush") - assert response.status_code == 200 - data = response.json() - assert "flushed" in data - assert "redis_keys" in data["flushed"] - assert "s3_objects" in data["flushed"] - - def test_flush_specific_remote_echoes_remote(self, client, patched_deps): - deps = patched_deps - deps["cache"].available = False - deps["storage"].client.list_objects_v2.return_value = {} - - response = client.put("/cache/flush?remote=alpine-test") - assert response.status_code == 200 - assert response.json()["remote"] == "alpine-test" - - def test_flush_all_deletes_redis_keys_when_cache_available(self, client, patched_deps): - deps = patched_deps - deps["cache"].available = True - redis_mock = MagicMock() - deps["cache"].client = redis_mock - # index:* returns keys; mutable:meta:* and metrics:* return nothing - redis_mock.keys.side_effect = [["index:test:abc", "index:test:def"], [], []] - deps["storage"].client.list_objects_v2.return_value = {} - - response = client.put("/cache/flush") - assert response.status_code == 200 - data = response.json() - assert data["flushed"]["redis_keys"] == 2 - redis_mock.delete.assert_called_once_with("index:test:abc", "index:test:def") - - -# --------------------------------------------------------------------------- -# Metrics endpoint GET /metrics -# --------------------------------------------------------------------------- - - -class TestMetricsEndpoint: - def test_returns_prometheus_text_by_default(self, client, patched_deps): - response = client.get("/metrics") - assert response.status_code == 200 - assert response.headers["content-type"].startswith("text/plain") - - -# --------------------------------------------------------------------------- -# Config endpoint GET /config -# --------------------------------------------------------------------------- - - -class TestConfigEndpoint: - def test_returns_config_with_remotes(self, client): - response = client.get("/config") - assert response.status_code == 200 - data = response.json() - assert "remotes" in data - assert "alpine-test" in data["remotes"] - - -# --------------------------------------------------------------------------- -# PyPI remote /api/v1/remote/pypi-test/... -# --------------------------------------------------------------------------- - - -class TestPyPIRemote: - def test_simple_index_is_mutable(self, client, patched_deps): - """simple/ paths are detected as mutable (package-type default).""" - deps = patched_deps - html = b"..." - deps["storage"].exists.return_value = True - deps["storage"].download_object.return_value = html - deps["cache"].is_mutable_file.return_value = True - deps["cache"].is_index_valid.return_value = True - - response = client.get("/api/v1/remote/pypi-test/simple/requests/") - assert response.status_code == 200 - deps["cache"].mark_index_cached.assert_not_called() - - def test_simple_index_urls_rewritten_to_proxy(self, client, patched_deps): - """files.pythonhosted.org URLs in a cached simple index are rewritten to our proxy.""" - deps = patched_deps - html = b"..." - deps["storage"].exists.return_value = True - deps["storage"].download_object.return_value = html - deps["cache"].is_mutable_file.return_value = True - deps["cache"].is_index_valid.return_value = True - - response = client.get("/api/v1/remote/pypi-test/simple/requests/") - assert response.status_code == 200 - assert b"files.pythonhosted.org" not in response.content - assert b"/api/v1/remote/pypi-test/packages/requests-2.31.0.tar.gz" in response.content - - def test_simple_index_content_type_is_html(self, client, patched_deps): - deps = patched_deps - deps["storage"].exists.return_value = True - deps["storage"].download_object.return_value = b"" - deps["cache"].is_mutable_file.return_value = True - deps["cache"].is_index_valid.return_value = True - - response = client.get("/api/v1/remote/pypi-test/simple/requests/") - assert response.status_code == 200 - assert "text/html" in response.headers["content-type"] - - def test_simple_index_cache_miss_fetches_upstream(self, client, patched_deps): - deps = patched_deps - html = b"..." - deps["storage"].exists.return_value = False - deps["storage"].download_object.return_value = html - deps["cache"].is_mutable_file.return_value = True - - with patch( - "artifactapi.artifact.proxy.cache_single_artifact", - new_callable=AsyncMock, - return_value={"status": "cached"}, - ) as mock_fetch: - response = client.get("/api/v1/remote/pypi-test/simple/requests/") - - mock_fetch.assert_called_once() - assert response.status_code == 200 - assert b"files.pythonhosted.org" not in response.content - - def test_wheel_file_immutable_returns_correct_content_type(self, client, patched_deps): - deps = patched_deps - deps["storage"].exists.return_value = True - deps["storage"].download_object.return_value = b"PK wheel bytes" - deps["cache"].is_mutable_file.return_value = False - - response = client.get("/api/v1/remote/pypi-test/packages/requests-2.31.0-py3-none-any.whl") - assert response.status_code == 200 - assert "application/zip" in response.headers["content-type"] - assert response.headers["X-Artifact-Source"] == "cache" - - def test_sdist_immutable_returns_correct_content_type(self, client, patched_deps): - deps = patched_deps - deps["storage"].exists.return_value = True - deps["storage"].download_object.return_value = b"tar bytes" - deps["cache"].is_mutable_file.return_value = False - - response = client.get("/api/v1/remote/pypi-test/packages/requests-2.31.0.tar.gz") - assert response.status_code == 200 - assert "application/gzip" in response.headers["content-type"] - - def test_unknown_extension_on_pypi_remote_returns_403(self, client, patched_deps): - """Paths that don't match immutable_patterns and aren't mutable are blocked.""" - response = client.get("/api/v1/remote/pypi-test/packages/requests.unknown") - assert response.status_code == 403 - - -# --------------------------------------------------------------------------- -# npm remote /api/v1/remote/npm-test/... -# --------------------------------------------------------------------------- - - -class TestNpmRemote: - def test_package_metadata_is_mutable(self, client, patched_deps): - """Top-level package metadata paths are detected as mutable.""" - deps = patched_deps - meta = b'{"name":"express","versions":{}}' - deps["storage"].exists.return_value = True - deps["storage"].download_object.return_value = meta - deps["cache"].is_mutable_file.return_value = True - deps["cache"].is_index_valid.return_value = True - - response = client.get("/api/v1/remote/npm-test/express") - assert response.status_code == 200 - deps["cache"].mark_index_cached.assert_not_called() - - def test_metadata_tarball_urls_rewritten_to_proxy(self, client, patched_deps): - """registry.npmjs.org tarball URLs in metadata JSON are rewritten to our proxy.""" - deps = patched_deps - meta = b'{"dist":{"tarball":"https://registry.npmjs.org/express/-/express-4.18.2.tgz"}}' - deps["storage"].exists.return_value = True - deps["storage"].download_object.return_value = meta - deps["cache"].is_mutable_file.return_value = True - deps["cache"].is_index_valid.return_value = True - - response = client.get("/api/v1/remote/npm-test/express") - assert response.status_code == 200 - assert b"registry.npmjs.org" not in response.content - assert b"/api/v1/remote/npm-test/express/-/express-4.18.2.tgz" in response.content - - def test_metadata_content_type_is_json(self, client, patched_deps): - deps = patched_deps - deps["storage"].exists.return_value = True - deps["storage"].download_object.return_value = b'{"name":"express"}' - deps["cache"].is_mutable_file.return_value = True - deps["cache"].is_index_valid.return_value = True - - response = client.get("/api/v1/remote/npm-test/express") - assert response.status_code == 200 - assert "application/json" in response.headers["content-type"] - - def test_scoped_package_metadata_rewritten(self, client, patched_deps): - """@scope/package metadata URLs are also rewritten back to the same npm-test remote.""" - deps = patched_deps - meta = b'{"dist":{"tarball":"https://registry.npmjs.org/@babel/core/-/core-7.21.0.tgz"}}' - deps["storage"].exists.return_value = True - deps["storage"].download_object.return_value = meta - deps["cache"].is_mutable_file.return_value = True - deps["cache"].is_index_valid.return_value = True - - response = client.get("/api/v1/remote/npm-test/@babel/core") - assert response.status_code == 200 - assert b"registry.npmjs.org" not in response.content - assert b"/api/v1/remote/npm-test/@babel/core/-/core-7.21.0.tgz" in response.content - - def test_tarball_not_rewritten(self, client, patched_deps): - """Tarball requests (.tgz) bypass URL rewriting and return binary.""" - deps = patched_deps - deps["storage"].exists.return_value = True - deps["storage"].download_object.return_value = b"\x1f\x8b tgz bytes" - deps["cache"].is_mutable_file.return_value = False - - response = client.get("/api/v1/remote/npm-test/express/-/express-4.18.2.tgz") - assert response.status_code == 200 - assert "application/gzip" in response.headers["content-type"] - assert response.headers["X-Artifact-Source"] == "cache" - - def test_metadata_cache_miss_fetches_upstream(self, client, patched_deps): - deps = patched_deps - meta = b'{"dist":{"tarball":"https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz"}}' - deps["storage"].exists.return_value = False - deps["storage"].download_object.return_value = meta - deps["cache"].is_mutable_file.return_value = True - - with patch( - "artifactapi.artifact.proxy.cache_single_artifact", - new_callable=AsyncMock, - return_value={"status": "cached"}, - ) as mock_fetch: - response = client.get("/api/v1/remote/npm-test/lodash") - - mock_fetch.assert_called_once() - assert response.status_code == 200 - assert b"registry.npmjs.org" not in response.content - - def test_tarball_immutable_allowed_on_npm_remote(self, client, patched_deps): - """Tarballs (.tgz) match immutable_patterns and are served without rewriting.""" - deps = patched_deps - deps["storage"].exists.return_value = True - deps["storage"].download_object.return_value = b"tgz bytes" - deps["cache"].is_mutable_file.return_value = False - - response = client.get("/api/v1/remote/npm-test/express/-/express-4.18.2.tgz") - assert response.status_code == 200 - assert "application/gzip" in response.headers["content-type"] - - -# --------------------------------------------------------------------------- -# Helm remote /api/v1/remote/helm-test/... -# --------------------------------------------------------------------------- - - -class TestHelmRemote: - def test_index_yaml_is_mutable(self, client, patched_deps): - """index.yaml is detected as mutable (package-type default).""" - deps = patched_deps - index = b"apiVersion: v1\nentries:\n vault:\n - urls:\n - https://helm.releases.hashicorp.com/vault-0.29.1.tgz\n" - deps["storage"].exists.return_value = True - deps["storage"].download_object.return_value = index - deps["cache"].is_mutable_file.return_value = True - deps["cache"].is_index_valid.return_value = True - - response = client.get("/api/v1/remote/helm-test/index.yaml") - assert response.status_code == 200 - deps["cache"].mark_index_cached.assert_not_called() - - def test_index_yaml_urls_rewritten_to_proxy(self, client, patched_deps): - """base_url chart URLs in a cached index.yaml are rewritten to our proxy.""" - deps = patched_deps - index = b"apiVersion: v1\nentries:\n vault:\n - urls:\n - https://helm.releases.hashicorp.com/vault-0.29.1.tgz\n" - deps["storage"].exists.return_value = True - deps["storage"].download_object.return_value = index - deps["cache"].is_mutable_file.return_value = True - deps["cache"].is_index_valid.return_value = True - - response = client.get("/api/v1/remote/helm-test/index.yaml") - assert response.status_code == 200 - assert b"helm.releases.hashicorp.com" not in response.content - assert b"/api/v1/remote/helm-test/vault-0.29.1.tgz" in response.content - - def test_index_yaml_content_type_is_yaml(self, client, patched_deps): - deps = patched_deps - deps["storage"].exists.return_value = True - deps["storage"].download_object.return_value = b"apiVersion: v1\nentries: {}\n" - deps["cache"].is_mutable_file.return_value = True - deps["cache"].is_index_valid.return_value = True - - response = client.get("/api/v1/remote/helm-test/index.yaml") - assert response.status_code == 200 - assert "text/yaml" in response.headers["content-type"] - - def test_chart_tarball_immutable_returns_gzip_content_type(self, client, patched_deps): - """Versioned chart tarballs match immutable_patterns and are served as binary.""" - deps = patched_deps - deps["storage"].exists.return_value = True - deps["storage"].download_object.return_value = b"\x1f\x8b chart bytes" - deps["cache"].is_mutable_file.return_value = False - - response = client.get("/api/v1/remote/helm-test/vault-0.29.1.tgz") - assert response.status_code == 200 - assert "application/gzip" in response.headers["content-type"] - assert response.headers["X-Artifact-Source"] == "cache" - - def test_index_yaml_cache_miss_fetches_upstream(self, client, patched_deps): - deps = patched_deps - index = b"apiVersion: v1\nentries:\n vault:\n - urls:\n - https://helm.releases.hashicorp.com/vault-0.29.1.tgz\n" - deps["storage"].exists.return_value = False - deps["storage"].download_object.return_value = index - deps["cache"].is_mutable_file.return_value = True - - with patch( - "artifactapi.artifact.proxy.cache_single_artifact", - new_callable=AsyncMock, - return_value={"status": "cached"}, - ) as mock_fetch: - response = client.get("/api/v1/remote/helm-test/index.yaml") - - mock_fetch.assert_called_once() - assert response.status_code == 200 - assert b"helm.releases.hashicorp.com" not in response.content - - def test_non_tgz_non_yaml_path_blocked_by_pattern(self, client, patched_deps): - """Paths that don't match immutable_patterns and aren't mutable are blocked.""" - deps = patched_deps - deps["cache"].is_mutable_file.return_value = False - - response = client.get("/api/v1/remote/helm-test/vault.zip") - assert response.status_code == 403 - - -# --------------------------------------------------------------------------- -# Puppet Forge remote /api/v1/remote/puppet-test/... -# --------------------------------------------------------------------------- - - -class TestPuppetRemote: - def test_module_metadata_is_mutable(self, client, patched_deps): - """v3/modules/ paths are detected as mutable (package-type default).""" - deps = patched_deps - deps["storage"].exists.return_value = True - deps["storage"].download_object.return_value = b'{"current_release":{"file_uri":"/v3/files/puppetlabs-stdlib-9.7.0.tar.gz"}}' - deps["cache"].is_mutable_file.return_value = True - deps["cache"].is_index_valid.return_value = True - - response = client.get("/api/v1/remote/puppet-test/v3/modules/puppetlabs-stdlib") - assert response.status_code == 200 - deps["cache"].mark_index_cached.assert_not_called() - - def test_releases_path_is_mutable(self, client, patched_deps): - """v3/releases paths are detected as mutable (package-type default).""" - deps = patched_deps - deps["storage"].exists.return_value = True - deps["storage"].download_object.return_value = b'{"file_uri":"/v3/files/puppetlabs-stdlib-9.7.0.tar.gz"}' - deps["cache"].is_mutable_file.return_value = True - deps["cache"].is_index_valid.return_value = True - - response = client.get("/api/v1/remote/puppet-test/v3/releases/puppetlabs-stdlib-9.7.0") - assert response.status_code == 200 - - def test_relative_file_uri_rewritten_to_absolute_proxy_url(self, client, patched_deps): - """Relative /v3/files/ paths in JSON responses are rewritten to absolute proxy URLs.""" - deps = patched_deps - meta = b'{"current_release":{"file_uri":"/v3/files/puppetlabs-stdlib-9.7.0.tar.gz","version":"9.7.0"}}' - deps["storage"].exists.return_value = True - deps["storage"].download_object.return_value = meta - deps["cache"].is_mutable_file.return_value = True - deps["cache"].is_index_valid.return_value = True - - response = client.get("/api/v1/remote/puppet-test/v3/modules/puppetlabs-stdlib") - assert response.status_code == 200 - assert b'"/v3/files/' not in response.content - assert b"/api/v1/remote/puppet-test/v3/files/puppetlabs-stdlib-9.7.0.tar.gz" in response.content - - def test_absolute_forge_url_rewritten_to_proxy(self, client, patched_deps): - """Absolute forgeapi.puppet.com URLs in JSON are rewritten to the proxy URL.""" - deps = patched_deps - meta = b'{"uri":"https://forgeapi.puppet.com/v3/modules/puppetlabs-stdlib"}' - deps["storage"].exists.return_value = True - deps["storage"].download_object.return_value = meta - deps["cache"].is_mutable_file.return_value = True - deps["cache"].is_index_valid.return_value = True - - response = client.get("/api/v1/remote/puppet-test/v3/modules/puppetlabs-stdlib") - assert response.status_code == 200 - assert b"forgeapi.puppet.com" not in response.content - assert b"/api/v1/remote/puppet-test" in response.content - - def test_metadata_content_type_is_json(self, client, patched_deps): - deps = patched_deps - deps["storage"].exists.return_value = True - deps["storage"].download_object.return_value = b'{"current_release":{}}' - deps["cache"].is_mutable_file.return_value = True - deps["cache"].is_index_valid.return_value = True - - response = client.get("/api/v1/remote/puppet-test/v3/modules/puppetlabs-concat") - assert response.status_code == 200 - assert "application/json" in response.headers["content-type"] - - def test_tarball_served_without_rewriting(self, client, patched_deps): - """Module tarballs (v3/files/*.tar.gz) are served as binary without URL rewriting.""" - deps = patched_deps - deps["storage"].exists.return_value = True - deps["storage"].download_object.return_value = b"\x1f\x8b tarball bytes" - deps["cache"].is_mutable_file.return_value = False - - response = client.get("/api/v1/remote/puppet-test/v3/files/puppetlabs-stdlib-9.7.0.tar.gz") - assert response.status_code == 200 - assert "application/gzip" in response.headers["content-type"] - assert response.headers["X-Artifact-Source"] == "cache" - - def test_tarball_not_blocked_by_immutable_pattern(self, client, patched_deps): - """v3/files/*.tar.gz matches the configured immutable_patterns and is allowed.""" - deps = patched_deps - deps["storage"].exists.return_value = True - deps["storage"].download_object.return_value = b"\x1f\x8b tarball bytes" - deps["cache"].is_mutable_file.return_value = False - - response = client.get("/api/v1/remote/puppet-test/v3/files/puppetlabs-inifile-6.2.0.tar.gz") - assert response.status_code == 200 - - def test_unknown_path_blocked(self, client, patched_deps): - """Paths outside v3/modules, v3/releases, and v3/files are blocked.""" - deps = patched_deps - deps["cache"].is_mutable_file.return_value = False - - response = client.get("/api/v1/remote/puppet-test/v3/users/puppetlabs") - assert response.status_code == 403 - - def test_metadata_cache_miss_fetches_upstream(self, client, patched_deps): - deps = patched_deps - meta = b'{"current_release":{"file_uri":"/v3/files/puppetlabs-stdlib-9.7.0.tar.gz"}}' - deps["storage"].exists.return_value = False - deps["storage"].download_object.return_value = meta - deps["cache"].is_mutable_file.return_value = True - - with patch( - "artifactapi.artifact.proxy.cache_single_artifact", - new_callable=AsyncMock, - return_value={"status": "cached"}, - ) as mock_fetch: - response = client.get("/api/v1/remote/puppet-test/v3/modules/puppetlabs-stdlib") - - mock_fetch.assert_called_once() - assert response.status_code == 200 - assert b'"/v3/files/' not in response.content - - -# --------------------------------------------------------------------------- -# Terraform registry remote (terraform-registry-test) -# --------------------------------------------------------------------------- - - -class TestTerraformRemote: - def test_versions_path_is_mutable(self, client, patched_deps): - """Provider versions listing is detected as mutable.""" - deps = patched_deps - deps["storage"].exists.return_value = True - deps["storage"].download_object.return_value = b'{"versions":[]}' - deps["cache"].is_mutable_file.return_value = True - deps["cache"].is_index_valid.return_value = True - - response = client.get("/api/v1/remote/terraform-registry-test/hashicorp/vault/versions") - assert response.status_code == 200 - deps["cache"].mark_index_cached.assert_not_called() - - def test_versions_returns_json_content_type(self, client, patched_deps): - deps = patched_deps - deps["storage"].exists.return_value = True - deps["storage"].download_object.return_value = b'{"versions":[]}' - deps["cache"].is_mutable_file.return_value = True - deps["cache"].is_index_valid.return_value = True - - response = client.get("/api/v1/remote/terraform-registry-test/hashicorp/vault/versions") - assert response.status_code == 200 - assert "application/json" in response.headers["content-type"] - - def test_download_info_download_url_rewritten(self, client, patched_deps): - """download_url in download-info JSON is rewritten to point to the releases proxy.""" - deps = patched_deps - download_info = json.dumps( - { - "os": "linux", - "arch": "amd64", - "filename": "terraform-provider-vault_0.28.0_linux_amd64.zip", - "download_url": "https://releases.hashicorp.com/terraform-provider-vault/0.28.0/terraform-provider-vault_0.28.0_linux_amd64.zip", - "shasums_url": "https://releases.hashicorp.com/terraform-provider-vault/0.28.0/terraform-provider-vault_0.28.0_SHA256SUMS", - "shasums_signature_url": "https://releases.hashicorp.com/terraform-provider-vault/0.28.0/terraform-provider-vault_0.28.0_SHA256SUMS.sig", - } - ).encode() - deps["storage"].exists.return_value = True - deps["storage"].download_object.return_value = download_info - deps["cache"].is_mutable_file.return_value = False - - response = client.get("/api/v1/remote/terraform-registry-test/hashicorp/vault/0.28.0/download/linux/amd64") - assert response.status_code == 200 - data = response.json() - assert "releases.hashicorp.com" not in data["download_url"] - assert "/api/v1/remote/hashicorp-releases-test/" in data["download_url"] - - def test_download_info_shasums_url_rewritten(self, client, patched_deps): - """shasums_url is also rewritten to the releases proxy.""" - deps = patched_deps - download_info = json.dumps( - { - "os": "linux", - "arch": "amd64", - "filename": "terraform-provider-vault_0.28.0_linux_amd64.zip", - "download_url": "https://releases.hashicorp.com/terraform-provider-vault/0.28.0/terraform-provider-vault_0.28.0_linux_amd64.zip", - "shasums_url": "https://releases.hashicorp.com/terraform-provider-vault/0.28.0/terraform-provider-vault_0.28.0_SHA256SUMS", - "shasums_signature_url": "https://releases.hashicorp.com/terraform-provider-vault/0.28.0/terraform-provider-vault_0.28.0_SHA256SUMS.sig", - } - ).encode() - deps["storage"].exists.return_value = True - deps["storage"].download_object.return_value = download_info - deps["cache"].is_mutable_file.return_value = False - - response = client.get("/api/v1/remote/terraform-registry-test/hashicorp/vault/0.28.0/download/linux/amd64") - assert response.status_code == 200 - data = response.json() - assert "/api/v1/remote/hashicorp-releases-test/" in data["shasums_url"] - assert "/api/v1/remote/hashicorp-releases-test/" in data["shasums_signature_url"] - assert "releases.hashicorp.com" not in data["shasums_url"] - assert "releases.hashicorp.com" not in data["shasums_signature_url"] - - def test_download_info_path_preserved(self, client, patched_deps): - """The path portion of the upstream URL is preserved when rewriting.""" - deps = patched_deps - zip_path = "/terraform-provider-vault/0.28.0/terraform-provider-vault_0.28.0_linux_amd64.zip" - download_info = json.dumps( - { - "download_url": f"https://releases.hashicorp.com{zip_path}", - "shasums_url": "https://releases.hashicorp.com/terraform-provider-vault/0.28.0/terraform-provider-vault_0.28.0_SHA256SUMS", - "shasums_signature_url": "https://releases.hashicorp.com/terraform-provider-vault/0.28.0/terraform-provider-vault_0.28.0_SHA256SUMS.sig", - } - ).encode() - deps["storage"].exists.return_value = True - deps["storage"].download_object.return_value = download_info - deps["cache"].is_mutable_file.return_value = False - - response = client.get("/api/v1/remote/terraform-registry-test/hashicorp/vault/0.28.0/download/linux/amd64") - assert response.status_code == 200 - data = response.json() - assert data["download_url"].endswith(zip_path) - - def test_zip_served_as_binary(self, client, patched_deps): - """Provider zip files are served as binary without JSON rewriting.""" - deps = patched_deps - deps["storage"].exists.return_value = True - deps["storage"].download_object.return_value = b"PK\x03\x04 zip bytes" - deps["cache"].is_mutable_file.return_value = False - - response = client.get( - "/api/v1/remote/hashicorp-releases-test/terraform-provider-vault/0.28.0/terraform-provider-vault_0.28.0_linux_amd64.zip" - ) - assert response.status_code == 200 - assert response.headers["X-Artifact-Source"] == "cache" - - def test_construct_url_prepends_v1_providers(self, client, patched_deps): - """Upstream URL for the terraform package type prepends /v1/providers/.""" - deps = patched_deps - deps["storage"].exists.return_value = False - - with patch( - "artifactapi.artifact.proxy.cache_single_artifact", - new_callable=AsyncMock, - return_value={"status": "cached"}, - ) as mock_fetch: - deps["storage"].download_object.return_value = b'{"versions":[]}' - deps["cache"].is_mutable_file.return_value = True - client.get("/api/v1/remote/terraform-registry-test/hashicorp/vault/versions") - - called_url = mock_fetch.call_args[0][0] - assert called_url == "https://registry.terraform.io/v1/providers/hashicorp/vault/versions" - - def test_versions_cache_miss_fetches_upstream(self, client, patched_deps): - deps = patched_deps - deps["storage"].exists.return_value = False - deps["storage"].download_object.return_value = b'{"versions":[]}' - deps["cache"].is_mutable_file.return_value = True - - with patch( - "artifactapi.artifact.proxy.cache_single_artifact", - new_callable=AsyncMock, - return_value={"status": "cached"}, - ) as mock_fetch: - response = client.get("/api/v1/remote/terraform-registry-test/hashicorp/vault/versions") - - mock_fetch.assert_called_once() - assert response.status_code == 200 - - -# --------------------------------------------------------------------------- -# Quarantine (quarantine-test remote: quarantine_new=True, quarantine_days=3) -# --------------------------------------------------------------------------- - - -class TestQuarantine: - def _recent_date(self, days_ago=1): - """Return an HTTP-format date string N days in the past (within quarantine window).""" - from datetime import datetime, timedelta - from email.utils import format_datetime - - dt = datetime.now(UTC) - timedelta(days=days_ago) - return format_datetime(dt, usegmt=True) - - def _old_date(self, days_ago=10): - """Return an HTTP-format date string N days in the past (outside quarantine window).""" - from datetime import datetime, timedelta - from email.utils import format_datetime - - dt = datetime.now(UTC) - timedelta(days=days_ago) - return format_datetime(dt, usegmt=True) - - def test_cache_miss_recent_artifact_quarantined(self, client, patched_deps): - """Cache miss: artifact published within quarantine window → 404.""" - deps = patched_deps - deps["storage"].exists.return_value = False - deps["storage"].download_object.return_value = b"content" - deps["cache"].is_mutable_file.return_value = False - - with patch( - "artifactapi.artifact.proxy.cache_single_artifact", - new_callable=AsyncMock, - return_value={"status": "cached", "last_modified": self._recent_date()}, - ): - response = client.get("/api/v1/remote/quarantine-test/some/path/package-1.0.tar.gz") - - assert response.status_code == 404 - assert "quarantined" in response.json()["detail"].lower() - - def test_cache_miss_old_artifact_allowed(self, client, patched_deps): - """Cache miss: artifact published outside quarantine window → 200.""" - deps = patched_deps - deps["storage"].exists.return_value = False - deps["storage"].download_object.return_value = b"content" - deps["cache"].is_mutable_file.return_value = False - - with patch( - "artifactapi.artifact.proxy.cache_single_artifact", - new_callable=AsyncMock, - return_value={"status": "cached", "last_modified": self._old_date()}, - ): - response = client.get("/api/v1/remote/quarantine-test/some/path/package-1.0.tar.gz") - - assert response.status_code == 200 - - def test_cache_miss_no_last_modified_fails_open(self, client, patched_deps): - """Cache miss: no Last-Modified header → fail open (200, not quarantined).""" - deps = patched_deps - deps["storage"].exists.return_value = False - deps["storage"].download_object.return_value = b"content" - deps["cache"].is_mutable_file.return_value = False - - with patch( - "artifactapi.artifact.proxy.cache_single_artifact", - new_callable=AsyncMock, - return_value={"status": "cached", "last_modified": None}, - ): - response = client.get("/api/v1/remote/quarantine-test/some/path/package-1.0.tar.gz") - - assert response.status_code == 200 - - def test_cache_hit_recent_artifact_quarantined(self, client, patched_deps): - """Cache hit: stored publish date within quarantine window → 404.""" - deps = patched_deps - deps["storage"].exists.return_value = True - deps["storage"].download_object.return_value = b"content" - deps["cache"].is_mutable_file.return_value = False - deps["cache"].get_artifact_published.return_value = self._recent_date() - - response = client.get("/api/v1/remote/quarantine-test/some/path/package-1.0.tar.gz") - - assert response.status_code == 404 - assert "quarantined" in response.json()["detail"].lower() - - def test_cache_hit_old_artifact_allowed(self, client, patched_deps): - """Cache hit: stored publish date outside quarantine window → 200.""" - deps = patched_deps - deps["storage"].exists.return_value = True - deps["storage"].download_object.return_value = b"content" - deps["cache"].is_mutable_file.return_value = False - deps["cache"].get_artifact_published.return_value = self._old_date() - - response = client.get("/api/v1/remote/quarantine-test/some/path/package-1.0.tar.gz") - - assert response.status_code == 200 - - def test_cache_hit_no_stored_date_fetches_upstream(self, client, patched_deps): - """Cache hit: no stored date → HEAD upstream to get Last-Modified.""" - deps = patched_deps - deps["storage"].exists.return_value = True - deps["storage"].download_object.return_value = b"content" - deps["cache"].is_mutable_file.return_value = False - deps["cache"].get_artifact_published.return_value = None - - with patch( - "artifactapi.artifact.proxy._fetch_last_modified", - new_callable=AsyncMock, - return_value=self._old_date(), - ) as mock_fetch: - response = client.get("/api/v1/remote/quarantine-test/some/path/package-1.0.tar.gz") - - mock_fetch.assert_called_once() - assert response.status_code == 200 - - def test_quarantine_disabled_allows_recent_artifact(self, client, patched_deps): - """quarantine_new=False: recent artifacts are not blocked.""" - deps = patched_deps - deps["storage"].exists.return_value = False - deps["storage"].download_object.return_value = b"content" - deps["cache"].is_mutable_file.return_value = False - - with patch( - "artifactapi.artifact.proxy.cache_single_artifact", - new_callable=AsyncMock, - return_value={"status": "cached", "last_modified": self._recent_date()}, - ): - response = client.get("/api/v1/remote/quarantine-disabled/some/path/package-1.0.tar.gz") - - assert response.status_code == 200 - - def test_quarantine_detail_includes_available_date(self, client, patched_deps): - """The 404 detail should include the date when the artifact becomes available.""" - deps = patched_deps - deps["storage"].exists.return_value = False - deps["storage"].download_object.return_value = b"content" - deps["cache"].is_mutable_file.return_value = False - - with patch( - "artifactapi.artifact.proxy.cache_single_artifact", - new_callable=AsyncMock, - return_value={"status": "cached", "last_modified": self._recent_date()}, - ): - response = client.get("/api/v1/remote/quarantine-test/some/path/package-1.0.tar.gz") - - assert response.status_code == 404 - detail = response.json()["detail"] - assert "available after" in detail - assert "3-day" in detail diff --git a/tests/test_storage.py b/tests/test_storage.py deleted file mode 100644 index e5b5c6a..0000000 --- a/tests/test_storage.py +++ /dev/null @@ -1,132 +0,0 @@ -"""Tests for S3Storage: get_object_key (pure logic) and I/O methods.""" - -import hashlib -from unittest.mock import MagicMock, patch - -import pytest -from botocore.exceptions import ClientError -from fastapi import HTTPException - -from artifactapi.storage import S3Storage - - -@pytest.fixture -def storage(): - """S3Storage with a mocked boto3 client.""" - with patch("boto3.client", return_value=MagicMock()): - s = S3Storage( - endpoint="localhost:9000", - access_key="testkey", - secret_key="testsecret", - bucket="testbucket", - secure=False, - ) - s.client = MagicMock() - return s - - -# --------------------------------------------------------------------------- -# get_object_key -# --------------------------------------------------------------------------- - - -class TestGetObjectKey: - def test_key_has_three_part_structure(self, storage): - # remote / hash-segment / filename - key = storage.get_object_key("myremote", "some/path/to/file.rpm") - parts = key.split("/") - assert len(parts) == 3 - assert parts[0] == "myremote" - assert parts[2] == "file.rpm" - assert len(parts[1]) == 16 # SHA-256 hex truncated to 16 chars - - def test_key_uses_sha256_of_directory_path(self, storage): - # Pin the hash algorithm, truncation length, and format in one assertion - key = storage.get_object_key("myremote", "some/path/to/file.rpm") - expected_hash = hashlib.sha256(b"some/path/to").hexdigest()[:16] - assert key == f"myremote/{expected_hash}/file.rpm" - - def test_different_remotes_give_different_keys(self, storage): - k1 = storage.get_object_key("remote-a", "path/to/file.rpm") - k2 = storage.get_object_key("remote-b", "path/to/file.rpm") - assert k1 != k2 - - def test_different_directories_give_different_keys(self, storage): - k1 = storage.get_object_key("myremote", "path/version-1/file.rpm") - k2 = storage.get_object_key("myremote", "path/version-2/file.rpm") - assert k1 != k2 - assert k1.split("/")[-1] == k2.split("/")[-1] == "file.rpm" - - def test_leading_slash_stripped(self, storage): - k1 = storage.get_object_key("myremote", "/path/to/file.rpm") - k2 = storage.get_object_key("myremote", "path/to/file.rpm") - assert k1 == k2 - - def test_file_with_no_directory(self, storage): - key = storage.get_object_key("myremote", "file.rpm") - assert key == "myremote/file.rpm" - - def test_docker_blob_uses_digest_path(self, storage): - digest = "a" * 64 # realistic 64-char SHA-256 hex string - path = f"library/nginx/blobs/sha256:{digest}" - key = storage.get_object_key("dockerhub", path) - assert key == f"dockerhub/blobs/sha256/{digest}" - - def test_docker_blob_deduplication_across_images(self, storage): - """Same blob digest pulled from different images maps to the same S3 key.""" - digest = "deadbeef" * 8 # 64-char hex - k1 = storage.get_object_key("dockerhub", f"library/nginx/blobs/sha256:{digest}") - k2 = storage.get_object_key("dockerhub", f"library/ubuntu/blobs/sha256:{digest}") - assert k1 == k2 - - def test_docker_blob_different_digests_different_keys(self, storage): - k1 = storage.get_object_key("dockerhub", "library/nginx/blobs/sha256:" + "a" * 64) - k2 = storage.get_object_key("dockerhub", "library/nginx/blobs/sha256:" + "b" * 64) - assert k1 != k2 - - def test_docker_blob_different_remotes_different_keys(self, storage): - digest = "abc" * 21 + "d" # 64-char hex - k1 = storage.get_object_key("remote-a", f"library/nginx/blobs/sha256:{digest}") - k2 = storage.get_object_key("remote-b", f"library/nginx/blobs/sha256:{digest}") - assert k1 != k2 - - -# --------------------------------------------------------------------------- -# get_url -# --------------------------------------------------------------------------- - - -class TestGetUrl: - def test_returns_http_url_for_insecure_endpoint(self, storage): - url = storage.get_url("myremote/abc123/file.rpm") - assert url == "http://localhost:9000/testbucket/myremote/abc123/file.rpm" - - def test_returns_http_url_for_secure_storage(self): - with patch("boto3.client", return_value=MagicMock()): - s = S3Storage(endpoint="s3.example.com", access_key="k", secret_key="s", bucket="b", secure=True) - s.client = MagicMock() - # get_url uses http:// always (direct internal access address, not the S3 protocol) - assert s.get_url("path/to/file.rpm") == "http://s3.example.com/b/path/to/file.rpm" - - -# --------------------------------------------------------------------------- -# upload / download_object -# --------------------------------------------------------------------------- - - -class TestUpload: - def test_upload_returns_s3_uri(self, storage): - storage.client.put_object.return_value = {} - result = storage.upload("myremote/abc123/file.rpm", b"content") - assert result == "s3://testbucket/myremote/abc123/file.rpm" - - -class TestDownloadObject: - def test_download_object_raises_404_on_client_error(self, storage): - storage.client.get_object.side_effect = ClientError( - {"Error": {"Code": "NoSuchKey", "Message": "The specified key does not exist"}}, - "GetObject", - ) - with pytest.raises(HTTPException) as exc_info: - storage.download_object("nonexistent/key") - assert exc_info.value.status_code == 404 diff --git a/tests/test_virtual.py b/tests/test_virtual.py deleted file mode 100644 index 9ad5be9..0000000 --- a/tests/test_virtual.py +++ /dev/null @@ -1,830 +0,0 @@ -"""Unit tests for the virtual repository handler (artifact/virtual.py).""" - -from datetime import UTC, date, datetime -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -import yaml - -from artifactapi.artifact.virtual import ( - _HANDLERS, - _entries_to_msgpack_safe, - _get_member_index, - _HelmDumper, - _HelmHandler, - _merge_helm_indexes, - _rewrite_urls, - _VirtualHandler, - _YamlDumperBase, - _YamlLoader, -) - -# --------------------------------------------------------------------------- -# Shared sample data -# --------------------------------------------------------------------------- - -_INDEX_A = b"""\ -apiVersion: v1 -entries: - vault: - - name: vault - version: "0.27.0" - urls: - - https://helm.releases.hashicorp.com/vault-0.27.0.tgz - consul: - - name: consul - version: "1.2.0" - urls: - - https://helm.releases.hashicorp.com/consul-1.2.0.tgz -generated: "2023-01-01T00:00:00.000Z" -""" - -_INDEX_B = b"""\ -apiVersion: v1 -entries: - nginx: - - name: nginx - version: "15.0.0" - urls: - - https://charts.example.com/nginx-15.0.0.tgz - vault: - - name: vault - version: "0.27.0" - urls: - - https://charts.example.com/vault-0.27.0.tgz - - name: vault - version: "0.26.0" - urls: - - https://charts.example.com/vault-0.26.0.tgz -generated: "2023-01-01T00:00:00.000Z" -""" - -_INDEX_SIMPLE = b"""\ -apiVersion: v1 -entries: - mychart: - - name: mychart - version: "1.0.0" - urls: - - https://helm.releases.hashicorp.com/mychart-1.0.0.tgz -generated: "2023-01-01T00:00:00.000Z" -""" - -_INDEX_RELATIVE = b"""\ -apiVersion: v1 -entries: - rancher: - - name: rancher - version: "2.13.1" - urls: - - rancher-2.13.1.tgz -generated: "2023-01-01T00:00:00.000Z" -""" - -_CFG_A = {"base_url": "https://helm.releases.hashicorp.com", "cache": {"mutable_ttl": 3600}} -_CFG_B = {"base_url": "https://charts.example.com", "cache": {"mutable_ttl": 1800}} - - -# --------------------------------------------------------------------------- -# _YamlLoader / _YamlDumperBase — C extension selection -# --------------------------------------------------------------------------- - - -class TestYamlExtensionSelection: - def test_loader_is_a_class(self): - assert isinstance(_YamlLoader, type) - - def test_dumper_base_is_a_class(self): - assert isinstance(_YamlDumperBase, type) - - def test_helm_dumper_uses_selected_base(self): - assert issubclass(_HelmDumper, _YamlDumperBase) - - def test_c_extensions_used_when_available(self): - try: - assert _YamlLoader is yaml.CSafeLoader - assert _YamlDumperBase is yaml.CDumper - except AttributeError: - assert _YamlLoader is yaml.SafeLoader - assert _YamlDumperBase is yaml.Dumper - - def test_loader_can_parse_yaml(self): - result = yaml.load(b"key: value", Loader=_YamlLoader) - assert result == {"key": "value"} - - -# --------------------------------------------------------------------------- -# _HelmDumper — datetime/date YAML serialization -# --------------------------------------------------------------------------- - - -class TestHelmDumper: - def _dump(self, value): - return yaml.dump({"v": value}, Dumper=_HelmDumper) - - def test_datetime_with_tz_includes_Z_suffix(self): - dt = datetime(2023, 6, 15, 12, 0, 0, tzinfo=UTC) - assert "Z" in self._dump(dt) - - def test_datetime_without_tz_has_no_Z_suffix(self): - dt = datetime(2023, 6, 15, 12, 0, 0) - assert "Z" not in self._dump(dt) - - def test_datetime_uses_T_separator_not_space(self): - dt = datetime(2023, 6, 15, 12, 30, 0, tzinfo=UTC) - assert "T12:30:00" in self._dump(dt) - - def test_date_serialized_as_iso_string(self): - assert "2023-01-15" in self._dump(date(2023, 1, 15)) - - def test_datetime_round_trips_as_string_not_python_datetime(self): - dt = datetime(2023, 6, 15, 12, 0, 0, tzinfo=UTC) - parsed = yaml.safe_load(self._dump(dt)) - # yaml.safe_load must not re-parse this as a datetime object - assert isinstance(parsed["v"], str) - - def test_date_round_trips_as_string_not_python_date(self): - parsed = yaml.safe_load(self._dump(date(2023, 1, 15))) - assert isinstance(parsed["v"], str) - - -# --------------------------------------------------------------------------- -# _HelmHandler -# --------------------------------------------------------------------------- - - -class TestHelmHandler: - def setup_method(self): - self.handler = _HelmHandler() - - def test_accepts_index_yaml(self): - assert self.handler.accepts_path("index.yaml") is True - - def test_rejects_tgz_path(self): - assert self.handler.accepts_path("vault-0.27.0.tgz") is False - - def test_rejects_subdirectory_index(self): - assert self.handler.accepts_path("charts/index.yaml") is False - - def test_rejects_empty_path(self): - assert self.handler.accepts_path("") is False - - def test_path_error_is_non_empty_string(self): - msg = self.handler.path_error() - assert isinstance(msg, str) and len(msg) > 0 - - def test_merge_returns_bytes(self): - result = self.handler.merge([_INDEX_A], [None], ["member-a"], [_CFG_A], "http://proxy.example.com") - assert isinstance(result, bytes) - - def test_merge_delegates_to_merge_helm_indexes(self): - with patch("artifactapi.artifact.virtual._merge_helm_indexes", return_value=b"merged") as mock_fn: - result = self.handler.merge([b"data"], [None], ["m"], [{}], "http://proxy") - mock_fn.assert_called_once_with([b"data"], [None], ["m"], [{}], "http://proxy") - assert result == b"merged" - - -# --------------------------------------------------------------------------- -# _HANDLERS registry -# --------------------------------------------------------------------------- - - -class TestHandlersRegistry: - def test_helm_handler_is_registered(self): - assert "helm" in _HANDLERS - assert isinstance(_HANDLERS["helm"], _HelmHandler) - - def test_helm_handler_satisfies_protocol(self): - assert isinstance(_HANDLERS["helm"], _VirtualHandler) - - -# --------------------------------------------------------------------------- -# _rewrite_urls -# --------------------------------------------------------------------------- - - -class TestRewriteUrls: - def _rewrite(self, urls, base_url="https://upstream.example.com", proxy_base="http://proxy.example.com", member_name="my-remote"): - return _rewrite_urls(urls, base_url, proxy_base, member_name) - - def test_absolute_url_matching_base_is_rewritten(self): - result = self._rewrite(["https://upstream.example.com/chart-1.0.0.tgz"]) - assert result == ["http://proxy.example.com/api/v1/remote/my-remote/chart-1.0.0.tgz"] - - def test_relative_url_is_prepended_with_proxy_remote(self): - result = self._rewrite(["chart-1.0.0.tgz"]) - assert result == ["http://proxy.example.com/api/v1/remote/my-remote/chart-1.0.0.tgz"] - - def test_relative_url_with_leading_slash(self): - result = self._rewrite(["/chart-1.0.0.tgz"]) - assert result == ["http://proxy.example.com/api/v1/remote/my-remote/chart-1.0.0.tgz"] - - def test_absolute_url_not_matching_base_is_unchanged(self): - result = self._rewrite(["https://other.example.com/chart-1.0.0.tgz"]) - assert result == ["https://other.example.com/chart-1.0.0.tgz"] - - def test_empty_url_list_returns_empty(self): - assert self._rewrite([]) == [] - - def test_multiple_urls_all_rewritten(self): - urls = ["https://upstream.example.com/a-1.0.0.tgz", "b-2.0.0.tgz"] - result = self._rewrite(urls) - assert result[0] == "http://proxy.example.com/api/v1/remote/my-remote/a-1.0.0.tgz" - assert result[1] == "http://proxy.example.com/api/v1/remote/my-remote/b-2.0.0.tgz" - - -# --------------------------------------------------------------------------- -# _merge_helm_indexes -# --------------------------------------------------------------------------- - - -class TestMergeHelmIndexes: - def _merge(self, raw_indexes, member_names, member_configs, proxy_base="http://proxy.example.com"): - return _merge_helm_indexes(raw_indexes, [None] * len(raw_indexes), member_names, member_configs, proxy_base) - - def _parse(self, raw): - return yaml.safe_load(raw) - - def test_single_member_all_charts_present(self): - index = self._parse(self._merge([_INDEX_A], ["member-a"], [_CFG_A])) - assert "vault" in index["entries"] - assert "consul" in index["entries"] - - def test_two_members_non_overlapping_charts_all_present(self): - index = self._parse(self._merge([_INDEX_A, _INDEX_B], ["member-a", "member-b"], [_CFG_A, _CFG_B])) - assert "vault" in index["entries"] - assert "consul" in index["entries"] - assert "nginx" in index["entries"] - - def test_first_member_wins_on_duplicate_name_and_version(self): - index = self._parse(self._merge([_INDEX_A, _INDEX_B], ["member-a", "member-b"], [_CFG_A, _CFG_B])) - v027 = next(e for e in index["entries"]["vault"] if e["version"] == "0.27.0") - assert "member-a" in v027["urls"][0] - - def test_absolute_urls_rewritten_to_proxy(self): - index = self._parse(self._merge([_INDEX_A], ["member-a"], [_CFG_A])) - url = index["entries"]["vault"][0]["urls"][0] - assert url == "http://proxy.example.com/api/v1/remote/member-a/vault-0.27.0.tgz" - - def test_relative_urls_rewritten_to_proxy(self): - cfg = {"base_url": "https://releases.rancher.com/server-charts/stable", "cache": {"mutable_ttl": 3600}} - index = self._parse(self._merge([_INDEX_RELATIVE], ["rancher-stable"], [cfg])) - url = index["entries"]["rancher"][0]["urls"][0] - assert url == "http://proxy.example.com/api/v1/remote/rancher-stable/rancher-2.13.1.tgz" - - def test_different_versions_of_same_chart_both_included(self): - index = self._parse(self._merge([_INDEX_A, _INDEX_B], ["member-a", "member-b"], [_CFG_A, _CFG_B])) - versions = {e["version"] for e in index["entries"]["vault"]} - assert "0.27.0" in versions - assert "0.26.0" in versions - - def test_malformed_yaml_from_member_is_skipped(self): - index = self._parse(self._merge([_INDEX_A, b"{bad yaml"], ["member-a", "bad"], [_CFG_A, _CFG_B])) - assert "vault" in index["entries"] - assert "consul" in index["entries"] - - def test_output_has_apiVersion_v1(self): - index = self._parse(self._merge([_INDEX_A], ["member-a"], [_CFG_A])) - assert index["apiVersion"] == "v1" - - def test_output_has_generated_field(self): - index = self._parse(self._merge([_INDEX_A], ["member-a"], [_CFG_A])) - assert "generated" in index - - def test_output_is_valid_yaml(self): - raw = self._merge([_INDEX_A, _INDEX_B], ["member-a", "member-b"], [_CFG_A, _CFG_B]) - assert isinstance(yaml.safe_load(raw), dict) - - def test_empty_index_from_member_produces_no_entries(self): - empty = b"apiVersion: v1\nentries: {}\ngenerated: '2023-01-01T00:00:00.000Z'\n" - index = self._parse(self._merge([empty], ["member-a"], [_CFG_A])) - assert index["entries"] == {} - - -# --------------------------------------------------------------------------- -# _get_member_index (async) -# --------------------------------------------------------------------------- - - -class TestGetMemberIndex: - @pytest.fixture - def storage(self): - m = MagicMock() - m.get_object_key.return_value = "member/key/index.yaml" - m.exists.return_value = False - m.download_object.return_value = b"cached bytes" - return m - - @pytest.fixture - def cache(self): - m = MagicMock() - m.is_index_valid.return_value = False - return m - - @pytest.fixture - def member_cfg(self): - return {"base_url": "https://helm.releases.hashicorp.com", "cache": {"mutable_ttl": 3600}} - - def _fake_response(self, content=b"upstream bytes"): - r = MagicMock() - r.content = content - r.raise_for_status = MagicMock() - return r - - def _patch_httpx(self, response): - mock_client_cls = patch("artifactapi.artifact.virtual.httpx.AsyncClient") - p = mock_client_cls.start() - mock_client = AsyncMock() - p.return_value.__aenter__.return_value = mock_client - mock_client.get.return_value = response - return mock_client_cls, mock_client - - async def test_cache_hit_returns_stored_bytes(self, storage, cache, member_cfg): - storage.exists.return_value = True - cache.is_index_valid.return_value = True - - _, _, _, raw_data, _ = await _get_member_index("m", member_cfg, "index.yaml", storage, cache) - - assert raw_data == b"cached bytes" - - async def test_cache_hit_does_not_fetch_upstream(self, storage, cache, member_cfg): - storage.exists.return_value = True - cache.is_index_valid.return_value = True - - with patch("artifactapi.artifact.virtual.httpx.AsyncClient") as mock_cls: - await _get_member_index("m", member_cfg, "index.yaml", storage, cache) - - mock_cls.assert_not_called() - - async def test_cache_hit_storage_error_falls_through_to_upstream(self, storage, cache, member_cfg): - storage.exists.return_value = True - cache.is_index_valid.return_value = True - storage.download_object.side_effect = Exception("S3 read error") - - with patch("artifactapi.artifact.virtual.httpx.AsyncClient") as mock_cls: - mock_client = AsyncMock() - mock_cls.return_value.__aenter__.return_value = mock_client - mock_client.get.return_value = self._fake_response(b"fresh bytes") - - _, _, _, raw_data, _ = await _get_member_index("m", member_cfg, "index.yaml", storage, cache) - - assert raw_data == b"fresh bytes" - - async def test_cache_miss_fetches_from_upstream(self, storage, cache, member_cfg): - with patch("artifactapi.artifact.virtual.httpx.AsyncClient") as mock_cls: - mock_client = AsyncMock() - mock_cls.return_value.__aenter__.return_value = mock_client - mock_client.get.return_value = self._fake_response() - - _, _, _, raw_data, _ = await _get_member_index("m", member_cfg, "index.yaml", storage, cache) - - assert raw_data == b"upstream bytes" - - async def test_cache_miss_stores_result_in_s3(self, storage, cache, member_cfg): - with patch("artifactapi.artifact.virtual.httpx.AsyncClient") as mock_cls: - mock_client = AsyncMock() - mock_cls.return_value.__aenter__.return_value = mock_client - mock_client.get.return_value = self._fake_response() - - await _get_member_index("m", member_cfg, "index.yaml", storage, cache) - - storage.upload.assert_called_once() - - async def test_cache_miss_marks_cache_with_configured_ttl(self, storage, cache, member_cfg): - with patch("artifactapi.artifact.virtual.httpx.AsyncClient") as mock_cls: - mock_client = AsyncMock() - mock_cls.return_value.__aenter__.return_value = mock_client - mock_client.get.return_value = self._fake_response() - - await _get_member_index("m", member_cfg, "index.yaml", storage, cache) - - cache.mark_index_cached.assert_called_once_with("m", "index.yaml", 3600) - - async def test_cache_miss_with_auth_sends_basic_auth_header(self, storage, cache): - cfg = { - "base_url": "https://private.example.com", - "username": "user", - "password": "pass", - "cache": {"mutable_ttl": 3600}, - } - with patch("artifactapi.artifact.virtual.httpx.AsyncClient") as mock_cls: - mock_client = AsyncMock() - mock_cls.return_value.__aenter__.return_value = mock_client - mock_client.get.return_value = self._fake_response() - - await _get_member_index("m", cfg, "index.yaml", storage, cache) - - headers = mock_client.get.call_args.kwargs["headers"] - assert "Authorization" in headers - assert headers["Authorization"].startswith("Basic ") - - async def test_no_credentials_sends_no_auth_header(self, storage, cache, member_cfg): - with patch("artifactapi.artifact.virtual.httpx.AsyncClient") as mock_cls: - mock_client = AsyncMock() - mock_cls.return_value.__aenter__.return_value = mock_client - mock_client.get.return_value = self._fake_response() - - await _get_member_index("m", member_cfg, "index.yaml", storage, cache) - - headers = mock_client.get.call_args.kwargs["headers"] - assert "Authorization" not in headers - - async def test_upstream_fetch_failure_returns_none(self, storage, cache, member_cfg): - with patch("artifactapi.artifact.virtual.httpx.AsyncClient") as mock_cls: - mock_client = AsyncMock() - mock_cls.return_value.__aenter__.return_value = mock_client - mock_client.get.side_effect = Exception("connection refused") - - _, _, _, raw_data, _ = await _get_member_index("m", member_cfg, "index.yaml", storage, cache) - - assert raw_data is None - - async def test_s3_upload_failure_still_returns_data(self, storage, cache, member_cfg): - storage.upload.side_effect = Exception("S3 write error") - - with patch("artifactapi.artifact.virtual.httpx.AsyncClient") as mock_cls: - mock_client = AsyncMock() - mock_cls.return_value.__aenter__.return_value = mock_client - mock_client.get.return_value = self._fake_response() - - _, _, _, raw_data, _ = await _get_member_index("m", member_cfg, "index.yaml", storage, cache) - - assert raw_data == b"upstream bytes" - - async def test_returns_ttl_from_config(self, storage, cache): - cfg = {"base_url": "https://example.com", "cache": {"mutable_ttl": 900}} - with patch("artifactapi.artifact.virtual.httpx.AsyncClient") as mock_cls: - mock_client = AsyncMock() - mock_cls.return_value.__aenter__.return_value = mock_client - mock_client.get.return_value = self._fake_response() - - _, _, ttl, _, _ = await _get_member_index("m", cfg, "index.yaml", storage, cache) - - assert ttl == 900 - - async def test_defaults_ttl_to_3600_when_not_configured(self, storage, cache): - cfg = {"base_url": "https://example.com"} - with patch("artifactapi.artifact.virtual.httpx.AsyncClient") as mock_cls: - mock_client = AsyncMock() - mock_cls.return_value.__aenter__.return_value = mock_client - mock_client.get.return_value = self._fake_response() - - _, _, ttl, _, _ = await _get_member_index("m", cfg, "index.yaml", storage, cache) - - assert ttl == 3600 - - -# --------------------------------------------------------------------------- -# Virtual route GET /api/v1/virtual/{name}/{path} -# --------------------------------------------------------------------------- - - -@pytest.fixture -def mock_storage_v(): - m = MagicMock() - m.get_object_key.return_value = "virtual/helm-virtual-test/index.yaml" - m.exists.return_value = False - m.download_object.return_value = b"apiVersion: v1\nentries: {}\n" - return m - - -@pytest.fixture -def mock_cache_v(): - m = MagicMock() - m.is_index_valid.return_value = False - m.available = False - m.client = None - return m - - -@pytest.fixture -def patched_virtual_deps(mock_storage_v, mock_cache_v): - import artifactapi.main as main_mod - - with ( - patch.object(main_mod, "storage", mock_storage_v), - patch.object(main_mod, "cache", mock_cache_v), - ): - yield {"storage": mock_storage_v, "cache": mock_cache_v} - - -class TestVirtualRoute: - def test_unknown_virtual_name_returns_404(self, client, patched_virtual_deps): - response = client.get("/api/v1/virtual/no-such-virtual/index.yaml") - assert response.status_code == 404 - - def test_non_virtual_name_returns_404(self, client, patched_virtual_deps): - # helm-test is in remotes, not virtuals - response = client.get("/api/v1/virtual/helm-test/index.yaml") - assert response.status_code == 404 - - def test_unsupported_package_returns_400(self, client, patched_virtual_deps): - # unsupported-virtual-test has package "rpm" - response = client.get("/api/v1/virtual/unsupported-virtual-test/index.yaml") - assert response.status_code == 400 - - def test_non_index_path_returns_404(self, client, patched_virtual_deps): - response = client.get("/api/v1/virtual/helm-virtual-test/vault-0.27.0.tgz") - assert response.status_code == 404 - - def test_no_members_returns_500(self, client, patched_virtual_deps): - response = client.get("/api/v1/virtual/empty-virtual-test/index.yaml") - assert response.status_code == 500 - - def test_virtual_cache_hit_returns_200(self, client, patched_virtual_deps): - deps = patched_virtual_deps - deps["storage"].exists.return_value = True - deps["cache"].is_index_valid.return_value = True - - response = client.get("/api/v1/virtual/helm-virtual-test/index.yaml") - assert response.status_code == 200 - - def test_virtual_cache_hit_content_type_is_yaml(self, client, patched_virtual_deps): - deps = patched_virtual_deps - deps["storage"].exists.return_value = True - deps["cache"].is_index_valid.return_value = True - - response = client.get("/api/v1/virtual/helm-virtual-test/index.yaml") - assert "text/yaml" in response.headers["content-type"] - - def test_virtual_cache_hit_returns_stored_content(self, client, patched_virtual_deps): - deps = patched_virtual_deps - deps["storage"].exists.return_value = True - deps["cache"].is_index_valid.return_value = True - deps["storage"].download_object.return_value = b"apiVersion: v1\nentries: {}\n" - - response = client.get("/api/v1/virtual/helm-virtual-test/index.yaml") - assert response.content == b"apiVersion: v1\nentries: {}\n" - - def test_virtual_cache_hit_skips_member_fetch(self, client, patched_virtual_deps): - deps = patched_virtual_deps - deps["storage"].exists.return_value = True - deps["cache"].is_index_valid.return_value = True - - with patch("artifactapi.artifact.virtual._get_member_index", new_callable=AsyncMock) as mock_get: - client.get("/api/v1/virtual/helm-virtual-test/index.yaml") - - mock_get.assert_not_called() - - def test_cache_miss_returns_200_with_yaml_content_type(self, client, patched_virtual_deps): - with patch("artifactapi.artifact.virtual._get_member_index", new_callable=AsyncMock) as mock_get: - mock_get.return_value = ("helm-test", _CFG_A, 3600, _INDEX_SIMPLE, None) - response = client.get("/api/v1/virtual/helm-virtual-test/index.yaml") - - assert response.status_code == 200 - assert "text/yaml" in response.headers["content-type"] - - def test_cache_miss_response_contains_merged_entries(self, client, patched_virtual_deps): - with patch("artifactapi.artifact.virtual._get_member_index", new_callable=AsyncMock) as mock_get: - mock_get.return_value = ("helm-test", _CFG_A, 3600, _INDEX_SIMPLE, None) - response = client.get("/api/v1/virtual/helm-virtual-test/index.yaml") - - index = yaml.safe_load(response.content) - assert "mychart" in index["entries"] - - def test_cache_miss_stores_result_in_s3(self, client, patched_virtual_deps): - deps = patched_virtual_deps - with patch("artifactapi.artifact.virtual._get_member_index", new_callable=AsyncMock) as mock_get: - mock_get.return_value = ("helm-test", _CFG_A, 3600, _INDEX_SIMPLE, None) - client.get("/api/v1/virtual/helm-virtual-test/index.yaml") - - deps["storage"].upload.assert_called_once() - - def test_cache_miss_marks_index_cached(self, client, patched_virtual_deps): - deps = patched_virtual_deps - with patch("artifactapi.artifact.virtual._get_member_index", new_callable=AsyncMock) as mock_get: - mock_get.return_value = ("helm-test", _CFG_A, 3600, _INDEX_SIMPLE, None) - client.get("/api/v1/virtual/helm-virtual-test/index.yaml") - - deps["cache"].mark_index_cached.assert_called_once() - - def test_cache_miss_uses_min_ttl_across_members(self, client, patched_virtual_deps): - deps = patched_virtual_deps - with patch("artifactapi.artifact.virtual._get_member_index", new_callable=AsyncMock) as mock_get: - mock_get.side_effect = [ - ("helm-test", _CFG_A, 3600, _INDEX_SIMPLE, None), - ("helm-member-2", _CFG_B, 1800, _INDEX_SIMPLE, None), - ] - client.get("/api/v1/virtual/helm-virtual-test/index.yaml") - - _, _, ttl = deps["cache"].mark_index_cached.call_args[0] - assert ttl == 1800 - - def test_all_members_unreachable_returns_502(self, client, patched_virtual_deps): - with patch("artifactapi.artifact.virtual._get_member_index", new_callable=AsyncMock) as mock_get: - mock_get.return_value = ("helm-test", _CFG_A, 3600, None, None) - response = client.get("/api/v1/virtual/helm-virtual-test/index.yaml") - - assert response.status_code == 502 - - def test_one_member_unreachable_still_returns_200(self, client, patched_virtual_deps): - with patch("artifactapi.artifact.virtual._get_member_index", new_callable=AsyncMock) as mock_get: - mock_get.side_effect = [ - ("helm-test", _CFG_A, 3600, _INDEX_SIMPLE, None), - ("helm-member-2", _CFG_B, 1800, None, None), - ] - response = client.get("/api/v1/virtual/helm-virtual-test/index.yaml") - - assert response.status_code == 200 - - def test_member_not_in_config_is_skipped(self, client, patched_virtual_deps): - import artifactapi.main as main_mod - - real_get = main_mod.config.get_remote_config - - def patched_get(name): - return None if name == "helm-member-2" else real_get(name) - - with ( - patch("artifactapi.artifact.virtual._get_member_index", new_callable=AsyncMock) as mock_get, - patch.object(main_mod.config, "get_remote_config", side_effect=patched_get), - ): - mock_get.return_value = ("helm-test", _CFG_A, 3600, _INDEX_SIMPLE, None) - response = client.get("/api/v1/virtual/helm-virtual-test/index.yaml") - - # only helm-test was available — should succeed - assert response.status_code == 200 - mock_get.assert_called_once() - - def test_s3_store_failure_still_returns_200(self, client, patched_virtual_deps): - deps = patched_virtual_deps - deps["storage"].upload.side_effect = Exception("S3 write error") - - with patch("artifactapi.artifact.virtual._get_member_index", new_callable=AsyncMock) as mock_get: - mock_get.return_value = ("helm-test", _CFG_A, 3600, _INDEX_SIMPLE, None) - response = client.get("/api/v1/virtual/helm-virtual-test/index.yaml") - - assert response.status_code == 200 - - -# --------------------------------------------------------------------------- -# _entries_to_msgpack_safe -# --------------------------------------------------------------------------- - - -class TestEntriesToMsgpackSafe: - def test_plain_string_values_pass_through(self): - entries = {"chart": [{"name": "chart", "version": "1.0.0", "urls": ["http://x/c.tgz"]}]} - result = _entries_to_msgpack_safe(entries) - assert result["chart"][0]["version"] == "1.0.0" - - def test_datetime_converted_to_iso_string(self): - dt = datetime(2023, 6, 15, 12, 0, 0, tzinfo=UTC) - entries = {"chart": [{"name": "chart", "version": "1.0.0", "created": dt}]} - result = _entries_to_msgpack_safe(entries) - assert isinstance(result["chart"][0]["created"], str) - assert "2023-06-15" in result["chart"][0]["created"] - - def test_date_converted_to_iso_string(self): - entries = {"chart": [{"name": "chart", "version": "1.0.0", "created": date(2023, 6, 15)}]} - result = _entries_to_msgpack_safe(entries) - assert result["chart"][0]["created"] == "2023-06-15" - - def test_empty_entries_returns_empty_dict(self): - assert _entries_to_msgpack_safe({}) == {} - - def test_multiple_versions_all_converted(self): - dt = datetime(2023, 1, 1, tzinfo=UTC) - entries = { - "chart": [ - {"name": "chart", "version": "1.0.0", "created": dt}, - {"name": "chart", "version": "2.0.0", "created": dt}, - ] - } - result = _entries_to_msgpack_safe(entries) - for v in result["chart"]: - assert isinstance(v["created"], str) - - def test_result_is_msgpack_serializable(self): - import msgpack - - dt = datetime(2023, 6, 15, 12, 0, 0, tzinfo=UTC) - entries = {"chart": [{"name": "chart", "version": "1.0.0", "created": dt, "urls": ["http://x/c.tgz"]}]} - safe = _entries_to_msgpack_safe(entries) - packed = msgpack.packb(safe, use_bin_type=True) - unpacked = msgpack.unpackb(packed, raw=False) - assert unpacked["chart"][0]["created"] == safe["chart"][0]["created"] - - -# --------------------------------------------------------------------------- -# _merge_helm_indexes — pre-parsed entries path -# --------------------------------------------------------------------------- - - -class TestMergeHelmIndexesWithParsed: - """Verify that pre-parsed entries (from msgpack) produce the same output as raw YAML.""" - - def _parse_entries(self, raw: bytes) -> dict: - index = yaml.safe_load(raw) - return index.get("entries") or {} - - def test_parsed_entries_produce_same_charts_as_raw(self): - parsed = self._parse_entries(_INDEX_A) - raw_result = yaml.safe_load(_merge_helm_indexes([_INDEX_A], [None], ["member-a"], [_CFG_A], "http://proxy.example.com")) - parsed_result = yaml.safe_load(_merge_helm_indexes([_INDEX_A], [parsed], ["member-a"], [_CFG_A], "http://proxy.example.com")) - assert set(raw_result["entries"].keys()) == set(parsed_result["entries"].keys()) - - def test_parsed_entries_urls_are_rewritten(self): - parsed = self._parse_entries(_INDEX_A) - result = yaml.safe_load(_merge_helm_indexes([_INDEX_A], [parsed], ["member-a"], [_CFG_A], "http://proxy.example.com")) - url = result["entries"]["vault"][0]["urls"][0] - assert "member-a" in url - assert "proxy.example.com" in url - - def test_none_parsed_falls_back_to_raw_bytes(self): - result = yaml.safe_load(_merge_helm_indexes([_INDEX_A], [None], ["member-a"], [_CFG_A], "http://proxy.example.com")) - assert "vault" in result["entries"] - - def test_mixed_parsed_and_raw_merge_correctly(self): - parsed_a = self._parse_entries(_INDEX_A) - result = yaml.safe_load( - _merge_helm_indexes( - [_INDEX_A, _INDEX_B], - [parsed_a, None], - ["member-a", "member-b"], - [_CFG_A, _CFG_B], - "http://proxy.example.com", - ) - ) - assert "vault" in result["entries"] - assert "nginx" in result["entries"] - - -# --------------------------------------------------------------------------- -# _get_member_index — msgpack cache behaviour -# --------------------------------------------------------------------------- - - -class TestGetMemberIndexMsgpack: - @pytest.fixture - def storage(self): - m = MagicMock() - m.get_object_key.side_effect = lambda name, path: f"{name}/{path}" - m.exists.return_value = False - m.download_object.return_value = _INDEX_SIMPLE - return m - - @pytest.fixture - def cache(self): - m = MagicMock() - m.is_index_valid.return_value = False - return m - - @pytest.fixture - def member_cfg(self): - return {"base_url": "https://helm.releases.hashicorp.com", "cache": {"mutable_ttl": 3600}} - - def _fake_response(self, content=_INDEX_SIMPLE): - r = MagicMock() - r.content = content - r.raise_for_status = MagicMock() - return r - - async def test_cache_hit_with_msgpack_returns_parsed_entries(self, storage, cache, member_cfg): - import msgpack - - entries = {"mychart": [{"name": "mychart", "version": "1.0.0", "urls": ["http://x/c.tgz"]}]} - packed = msgpack.packb(entries, use_bin_type=True) - - storage.exists.side_effect = lambda key: True - cache.is_index_valid.return_value = True - storage.download_object.side_effect = lambda key: packed if key.endswith("index.msgpack") else _INDEX_SIMPLE - - _, _, _, raw_data, parsed = await _get_member_index("m", member_cfg, "index.yaml", storage, cache) - - assert parsed == entries - - async def test_cache_miss_builds_msgpack_and_returns_parsed(self, storage, cache, member_cfg): - with patch("artifactapi.artifact.virtual.httpx.AsyncClient") as mock_cls: - mock_client = AsyncMock() - mock_cls.return_value.__aenter__.return_value = mock_client - mock_client.get.return_value = self._fake_response() - - _, _, _, raw_data, parsed = await _get_member_index("m", member_cfg, "index.yaml", storage, cache) - - assert raw_data == _INDEX_SIMPLE - assert isinstance(parsed, dict) - assert "mychart" in parsed - - async def test_broken_msgpack_rebuilds_from_raw_yaml(self, storage, cache, member_cfg): - storage.exists.side_effect = lambda key: True - cache.is_index_valid.return_value = True - storage.download_object.side_effect = lambda key: b"not-valid-msgpack" if key.endswith("index.msgpack") else _INDEX_SIMPLE - - _, _, _, raw_data, parsed = await _get_member_index("m", member_cfg, "index.yaml", storage, cache) - - assert raw_data == _INDEX_SIMPLE - # Falls back to YAML parse and rebuilds msgpack — entries are returned - assert isinstance(parsed, dict) - assert "mychart" in parsed - - async def test_upstream_failure_returns_none_for_both(self, storage, cache, member_cfg): - with patch("artifactapi.artifact.virtual.httpx.AsyncClient") as mock_cls: - mock_client = AsyncMock() - mock_cls.return_value.__aenter__.return_value = mock_client - mock_client.get.side_effect = Exception("timeout") - - _, _, _, raw_data, parsed = await _get_member_index("m", member_cfg, "index.yaml", storage, cache) - - assert raw_data is None - assert parsed is None diff --git a/tox.ini b/tox.ini deleted file mode 100644 index bf00acc..0000000 --- a/tox.ini +++ /dev/null @@ -1,8 +0,0 @@ -[tox] -envlist = py311 -isolated_build = true - -[testenv] -extras = dev -commands = - pytest {posargs:tests} diff --git a/ui/.gitignore b/ui/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/ui/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/ui/Dockerfile.ui b/ui/Dockerfile.ui new file mode 100644 index 0000000..372b0ea --- /dev/null +++ b/ui/Dockerfile.ui @@ -0,0 +1,18 @@ +FROM node:22-alpine AS builder + +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN npm ci + +COPY . . +RUN npm run build + +FROM nginx:alpine + +COPY --from=builder /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 0000000..a5a02bd --- /dev/null +++ b/ui/index.html @@ -0,0 +1,12 @@ + + + + + + ArtifactAPI + + +
+ + + diff --git a/ui/nginx.conf b/ui/nginx.conf new file mode 100644 index 0000000..4a0acd5 --- /dev/null +++ b/ui/nginx.conf @@ -0,0 +1,37 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + location /api/ { + 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 /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; + } +} diff --git a/ui/package-lock.json b/ui/package-lock.json new file mode 100644 index 0000000..71f5be0 --- /dev/null +++ b/ui/package-lock.json @@ -0,0 +1,2758 @@ +{ + "name": "artifactapi-ui", + "version": "0.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "artifactapi-ui", + "version": "0.0.0", + "dependencies": { + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-router-dom": "^7.6.0" + }, + "devDependencies": { + "@types/react": "^19.1.0", + "@types/react-dom": "^19.1.0", + "@vitejs/plugin-react": "^4.5.0", + "typescript": "~5.8.0", + "vite": "^6.3.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz", + "integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz", + "integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz", + "integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz", + "integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz", + "integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz", + "integrity": "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz", + "integrity": "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz", + "integrity": "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz", + "integrity": "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.1.tgz", + "integrity": "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.1.tgz", + "integrity": "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.1.tgz", + "integrity": "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.1.tgz", + "integrity": "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.1.tgz", + "integrity": "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.1.tgz", + "integrity": "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.1.tgz", + "integrity": "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.1.tgz", + "integrity": "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.1.tgz", + "integrity": "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.1.tgz", + "integrity": "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.1.tgz", + "integrity": "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.1.tgz", + "integrity": "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.1.tgz", + "integrity": "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.1.tgz", + "integrity": "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.1.tgz", + "integrity": "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.1.tgz", + "integrity": "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.34", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.34.tgz", + "integrity": "sha512-IMDedajPifLnHNY0X9n8hKxRTQ6/eTHwr5bDo04WnuqxyKw6LYtQywCuuqPZwhl3aBXMvQpJov42GLCwRRdQzw==", + "dev": true, + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001797", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz", + "integrity": "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.368", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.368.tgz", + "integrity": "sha512-7RckJJK4uESJF9PxvfMWd3TGqIiieUTG4HxnKaKuIpGbcr+r2ZEB3g2gAhCP3Fqm42vJSzLfgab9eva/C4/XVw==", + "dev": true + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.17.0.tgz", + "integrity": "sha512-FDELK7rTMlCHO5+reyXsPlmfr7N1F91lPHsWYfMEGQm/KQ+F4JFM8jGoeQDmDvdTs93Fw9aSilH+uKRb4/jXvQ==", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.17.0.tgz", + "integrity": "sha512-fyU2yjGups/hE6Xz0I5ZYbVL8Gx29eCjgpHaRaTaVU+OOAdfRX05KsvyRm0GO8YQwOkhpU3MurW1jyMUJn+zSw==", + "dependencies": { + "react-router": "7.17.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/rollup": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz", + "integrity": "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.61.1", + "@rollup/rollup-android-arm64": "4.61.1", + "@rollup/rollup-darwin-arm64": "4.61.1", + "@rollup/rollup-darwin-x64": "4.61.1", + "@rollup/rollup-freebsd-arm64": "4.61.1", + "@rollup/rollup-freebsd-x64": "4.61.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.61.1", + "@rollup/rollup-linux-arm-musleabihf": "4.61.1", + "@rollup/rollup-linux-arm64-gnu": "4.61.1", + "@rollup/rollup-linux-arm64-musl": "4.61.1", + "@rollup/rollup-linux-loong64-gnu": "4.61.1", + "@rollup/rollup-linux-loong64-musl": "4.61.1", + "@rollup/rollup-linux-ppc64-gnu": "4.61.1", + "@rollup/rollup-linux-ppc64-musl": "4.61.1", + "@rollup/rollup-linux-riscv64-gnu": "4.61.1", + "@rollup/rollup-linux-riscv64-musl": "4.61.1", + "@rollup/rollup-linux-s390x-gnu": "4.61.1", + "@rollup/rollup-linux-x64-gnu": "4.61.1", + "@rollup/rollup-linux-x64-musl": "4.61.1", + "@rollup/rollup-openbsd-x64": "4.61.1", + "@rollup/rollup-openharmony-arm64": "4.61.1", + "@rollup/rollup-win32-arm64-msvc": "4.61.1", + "@rollup/rollup-win32-ia32-msvc": "4.61.1", + "@rollup/rollup-win32-x64-gnu": "4.61.1", + "@rollup/rollup-win32-x64-msvc": "4.61.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + } + }, + "dependencies": { + "@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + } + }, + "@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true + }, + "@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + } + }, + "@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "requires": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + } + }, + "@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "requires": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + } + }, + "@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true + }, + "@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "requires": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + } + }, + "@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "requires": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + } + }, + "@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true + }, + "@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true + }, + "@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true + }, + "@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true + }, + "@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "requires": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + } + }, + "@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "requires": { + "@babel/types": "^7.29.7" + } + }, + "@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.29.7" + } + }, + "@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.29.7" + } + }, + "@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + } + }, + "@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + } + }, + "@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "requires": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + } + }, + "@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "dev": true, + "optional": true + }, + "@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "dev": true, + "optional": true + }, + "@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "dev": true, + "optional": true + }, + "@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "dev": true, + "optional": true + }, + "@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "dev": true, + "optional": true + }, + "@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "dev": true, + "optional": true + }, + "@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "dev": true, + "optional": true + }, + "@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "dev": true, + "optional": true + }, + "@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "dev": true, + "optional": true + }, + "@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "dev": true, + "optional": true + }, + "@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "dev": true, + "optional": true + }, + "@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "dev": true, + "optional": true + }, + "@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "dev": true, + "optional": true + }, + "@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "dev": true, + "optional": true + }, + "@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "dev": true, + "optional": true + }, + "@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "dev": true, + "optional": true + }, + "@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "dev": true, + "optional": true + }, + "@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "dev": true, + "optional": true + }, + "@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "dev": true, + "optional": true + }, + "@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "dev": true, + "optional": true + }, + "@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "dev": true, + "optional": true + }, + "@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "dev": true, + "optional": true + }, + "@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "dev": true, + "optional": true + }, + "@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "dev": true, + "optional": true + }, + "@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "dev": true, + "optional": true + }, + "@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "dev": true, + "optional": true + }, + "@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "requires": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "requires": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true + }, + "@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true + }, + "@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "requires": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true + }, + "@rollup/rollup-android-arm-eabi": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz", + "integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==", + "dev": true, + "optional": true + }, + "@rollup/rollup-android-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz", + "integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==", + "dev": true, + "optional": true + }, + "@rollup/rollup-darwin-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz", + "integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==", + "dev": true, + "optional": true + }, + "@rollup/rollup-darwin-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz", + "integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==", + "dev": true, + "optional": true + }, + "@rollup/rollup-freebsd-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz", + "integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==", + "dev": true, + "optional": true + }, + "@rollup/rollup-freebsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz", + "integrity": "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz", + "integrity": "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-arm-musleabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz", + "integrity": "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-arm64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz", + "integrity": "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-arm64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.1.tgz", + "integrity": "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-loong64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.1.tgz", + "integrity": "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-loong64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.1.tgz", + "integrity": "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-ppc64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.1.tgz", + "integrity": "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-ppc64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.1.tgz", + "integrity": "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-riscv64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.1.tgz", + "integrity": "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-riscv64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.1.tgz", + "integrity": "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-s390x-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.1.tgz", + "integrity": "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.1.tgz", + "integrity": "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-x64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.1.tgz", + "integrity": "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==", + "dev": true, + "optional": true + }, + "@rollup/rollup-openbsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.1.tgz", + "integrity": "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==", + "dev": true, + "optional": true + }, + "@rollup/rollup-openharmony-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.1.tgz", + "integrity": "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==", + "dev": true, + "optional": true + }, + "@rollup/rollup-win32-arm64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.1.tgz", + "integrity": "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==", + "dev": true, + "optional": true + }, + "@rollup/rollup-win32-ia32-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.1.tgz", + "integrity": "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==", + "dev": true, + "optional": true + }, + "@rollup/rollup-win32-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.1.tgz", + "integrity": "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==", + "dev": true, + "optional": true + }, + "@rollup/rollup-win32-x64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.1.tgz", + "integrity": "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==", + "dev": true, + "optional": true + }, + "@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "requires": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "requires": { + "@babel/types": "^7.0.0" + } + }, + "@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "requires": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "requires": { + "@babel/types": "^7.28.2" + } + }, + "@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true + }, + "@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "requires": { + "csstype": "^3.2.2" + } + }, + "@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "requires": {} + }, + "@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "requires": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + } + }, + "baseline-browser-mapping": { + "version": "2.10.34", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.34.tgz", + "integrity": "sha512-IMDedajPifLnHNY0X9n8hKxRTQ6/eTHwr5bDo04WnuqxyKw6LYtQywCuuqPZwhl3aBXMvQpJov42GLCwRRdQzw==", + "dev": true + }, + "browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "requires": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + } + }, + "caniuse-lite": { + "version": "1.0.30001797", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz", + "integrity": "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==", + "dev": true + }, + "convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==" + }, + "csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true + }, + "debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "requires": { + "ms": "^2.1.3" + } + }, + "electron-to-chromium": { + "version": "1.5.368", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.368.tgz", + "integrity": "sha512-7RckJJK4uESJF9PxvfMWd3TGqIiieUTG4HxnKaKuIpGbcr+r2ZEB3g2gAhCP3Fqm42vJSzLfgab9eva/C4/XVw==", + "dev": true + }, + "esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "requires": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true + }, + "fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "requires": {} + }, + "fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "optional": true + }, + "gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true + }, + "js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, + "jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true + }, + "json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true + }, + "lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "requires": { + "yallist": "^3.0.2" + } + }, + "ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true + }, + "node-releases": { + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "dev": true + }, + "picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true + }, + "postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "requires": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + } + }, + "react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==" + }, + "react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "requires": { + "scheduler": "^0.27.0" + } + }, + "react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true + }, + "react-router": { + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.17.0.tgz", + "integrity": "sha512-FDELK7rTMlCHO5+reyXsPlmfr7N1F91lPHsWYfMEGQm/KQ+F4JFM8jGoeQDmDvdTs93Fw9aSilH+uKRb4/jXvQ==", + "requires": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + } + }, + "react-router-dom": { + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.17.0.tgz", + "integrity": "sha512-fyU2yjGups/hE6Xz0I5ZYbVL8Gx29eCjgpHaRaTaVU+OOAdfRX05KsvyRm0GO8YQwOkhpU3MurW1jyMUJn+zSw==", + "requires": { + "react-router": "7.17.0" + } + }, + "rollup": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz", + "integrity": "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==", + "dev": true, + "requires": { + "@rollup/rollup-android-arm-eabi": "4.61.1", + "@rollup/rollup-android-arm64": "4.61.1", + "@rollup/rollup-darwin-arm64": "4.61.1", + "@rollup/rollup-darwin-x64": "4.61.1", + "@rollup/rollup-freebsd-arm64": "4.61.1", + "@rollup/rollup-freebsd-x64": "4.61.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.61.1", + "@rollup/rollup-linux-arm-musleabihf": "4.61.1", + "@rollup/rollup-linux-arm64-gnu": "4.61.1", + "@rollup/rollup-linux-arm64-musl": "4.61.1", + "@rollup/rollup-linux-loong64-gnu": "4.61.1", + "@rollup/rollup-linux-loong64-musl": "4.61.1", + "@rollup/rollup-linux-ppc64-gnu": "4.61.1", + "@rollup/rollup-linux-ppc64-musl": "4.61.1", + "@rollup/rollup-linux-riscv64-gnu": "4.61.1", + "@rollup/rollup-linux-riscv64-musl": "4.61.1", + "@rollup/rollup-linux-s390x-gnu": "4.61.1", + "@rollup/rollup-linux-x64-gnu": "4.61.1", + "@rollup/rollup-linux-x64-musl": "4.61.1", + "@rollup/rollup-openbsd-x64": "4.61.1", + "@rollup/rollup-openharmony-arm64": "4.61.1", + "@rollup/rollup-win32-arm64-msvc": "4.61.1", + "@rollup/rollup-win32-ia32-msvc": "4.61.1", + "@rollup/rollup-win32-x64-gnu": "4.61.1", + "@rollup/rollup-win32-x64-msvc": "4.61.1", + "@types/estree": "1.0.9", + "fsevents": "~2.3.2" + } + }, + "scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==" + }, + "semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true + }, + "set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==" + }, + "source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true + }, + "tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "requires": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + } + }, + "typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true + }, + "update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "requires": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + } + }, + "vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "requires": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "fsevents": "~2.3.3", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + } + }, + "yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + } + } +} diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 0000000..59a9fad --- /dev/null +++ b/ui/package.json @@ -0,0 +1,23 @@ +{ + "name": "artifactapi-ui", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-router-dom": "^7.6.0" + }, + "devDependencies": { + "@types/react": "^19.1.0", + "@types/react-dom": "^19.1.0", + "@vitejs/plugin-react": "^4.5.0", + "typescript": "~5.8.0", + "vite": "^6.3.0" + } +} diff --git a/ui/src/App.css b/ui/src/App.css new file mode 100644 index 0000000..c7e6091 --- /dev/null +++ b/ui/src/App.css @@ -0,0 +1,88 @@ +.app { + display: flex; + min-height: 100vh; +} + +.sidebar { + width: 220px; + background: var(--bg-surface); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + padding: 20px 0; + flex-shrink: 0; +} + +.sidebar-brand { + display: flex; + align-items: center; + gap: 10px; + padding: 0 20px 24px; + border-bottom: 1px solid var(--border); + margin-bottom: 16px; +} + +.brand-icon { + font-size: 1.5em; + color: var(--accent); +} + +.brand-text { + font-weight: 700; + font-size: 1.05em; + color: var(--text-bright); +} + +.sidebar-nav { + display: flex; + flex-direction: column; + gap: 2px; + padding: 0 8px; + flex: 1; +} + +.sidebar-nav a { + display: block; + padding: 8px 12px; + border-radius: var(--radius); + color: var(--text-muted); + font-size: 0.9em; + font-weight: 500; + transition: all 0.15s; +} + +.sidebar-nav a:hover { + background: var(--bg-elevated); + color: var(--text); + text-decoration: none; +} + +.sidebar-nav a.active { + background: var(--accent); + color: #fff; +} + +.sidebar-footer { + padding: 16px 20px 0; + border-top: 1px solid var(--border); + margin-top: auto; +} + +.version { + font-size: 0.75em; + color: var(--text-muted); + font-family: var(--font-mono); +} + +.content { + flex: 1; + padding: 32px 40px; + overflow-y: auto; +} + +.page-title { + font-size: 1.5em; + font-weight: 700; + color: var(--text-bright); + margin-bottom: 24px; +} diff --git a/ui/src/App.tsx b/ui/src/App.tsx new file mode 100644 index 0000000..422f3e4 --- /dev/null +++ b/ui/src/App.tsx @@ -0,0 +1,40 @@ +import { Routes, Route, NavLink } from 'react-router-dom'; +import { Dashboard } from './pages/Dashboard'; +import { Remotes } from './pages/Remotes'; +import { RemoteDetail } from './pages/RemoteDetail'; +import { Virtuals } from './pages/Virtuals'; +import { Objects } from './pages/Objects'; +import { Probe } from './pages/Probe'; +import './App.css'; + +export function App() { + return ( +
+ +
+ + } /> + } /> + } /> + } /> + } /> + } /> + +
+
+ ); +} diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts new file mode 100644 index 0000000..29e1495 --- /dev/null +++ b/ui/src/api/client.ts @@ -0,0 +1,43 @@ +import type { Remote, Virtual, Artifact, OverviewStats, RemoteStatRow, HealthStatus, ProbeResult } from './types'; + +const BASE = ''; + +async function fetchJSON(path: string, init?: RequestInit): Promise { + const resp = await fetch(`${BASE}${path}`, { + ...init, + headers: { 'Content-Type': 'application/json', ...init?.headers }, + }); + if (!resp.ok) { + const text = await resp.text(); + throw new Error(`${resp.status}: ${text}`); + } + if (resp.status === 204) return undefined as T; + return resp.json(); +} + +export const api = { + health: () => fetchJSON('/api/v2/health'), + stats: () => fetchJSON('/api/v2/stats'), + topRemotes: () => fetchJSON('/api/v2/stats/top-remotes'), + + listRemotes: () => fetchJSON('/api/v2/remotes'), + getRemote: (name: string) => fetchJSON(`/api/v2/remotes/${name}`), + + listVirtuals: () => fetchJSON('/api/v2/virtuals'), + getVirtual: (name: string) => fetchJSON(`/api/v2/virtuals/${name}`), + + listObjects: (remote: string, page = 1, perPage = 50) => + fetchJSON(`/api/v2/remotes/${remote}/objects?page=${page}&per_page=${perPage}`), + + evictObject: (remote: string, path: string) => + fetchJSON(`/api/v2/remotes/${remote}/objects/${path}`, { method: 'DELETE' }), + + flushRemoteCache: (remote: string) => + fetchJSON(`/api/v2/remotes/${remote}/cache`, { method: 'DELETE' }), + + probe: (remote: string, path: string) => + fetchJSON('/api/v2/probe', { + method: 'POST', + body: JSON.stringify({ remote, path }), + }), +}; diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts new file mode 100644 index 0000000..dccb1bd --- /dev/null +++ b/ui/src/api/types.ts @@ -0,0 +1,80 @@ +export interface Remote { + name: string; + package_type: string; + base_url: string; + description: string; + username?: string; + immutable_ttl: number; + mutable_ttl: number; + check_mutable: boolean; + patterns: string[]; + blocklist: string[]; + mutable_patterns: string[]; + immutable_patterns: string[]; + ban_tags_enabled: boolean; + ban_tags: string[]; + quarantine_enabled: boolean; + quarantine_days: number; + stale_on_error: boolean; + releases_remote: string; + managed_by: string; + created_at: string; + updated_at: string; +} + +export interface Virtual { + name: string; + package_type: string; + description: string; + members: string[]; + managed_by: string; + created_at: string; + updated_at: string; +} + +export interface Artifact { + id: number; + remote_name: string; + path: string; + content_hash: string; + upstream_etag: string; + first_seen_at: string; + last_fetched_at: string; + last_accessed_at: string; + fetch_count: number; + access_count: number; + size_bytes: number; + content_type: string; +} + +export interface OverviewStats { + total_remotes: number; + total_objects: number; + total_bytes: number; + total_blobs_deduped: number; + bandwidth_saved_30d: number; +} + +export interface RemoteStatRow { + name: string; + object_count: number; + total_bytes: number; + requests_30d: number; +} + +export interface HealthStatus { + status: string; + postgres: string; + redis: string; + s3: string; +} + +export interface ProbeResult { + status: number; + source: string; + content_type: string; + size_bytes: number; + headers: Record; + duration_ms: number; + error: string; +} diff --git a/ui/src/components/Badge.css b/ui/src/components/Badge.css new file mode 100644 index 0000000..84fb8c6 --- /dev/null +++ b/ui/src/components/Badge.css @@ -0,0 +1,35 @@ +.badge { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 0.75em; + font-weight: 600; + font-family: var(--font-mono); + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.badge-default { + background: var(--bg-elevated); + color: var(--text-muted); +} + +.badge-green { + background: rgba(34, 197, 94, 0.15); + color: var(--green); +} + +.badge-yellow { + background: rgba(234, 179, 8, 0.15); + color: var(--yellow); +} + +.badge-red { + background: rgba(239, 68, 68, 0.15); + color: var(--red); +} + +.badge-blue { + background: rgba(59, 130, 246, 0.15); + color: var(--accent); +} diff --git a/ui/src/components/Badge.tsx b/ui/src/components/Badge.tsx new file mode 100644 index 0000000..c7af3ce --- /dev/null +++ b/ui/src/components/Badge.tsx @@ -0,0 +1,10 @@ +import './Badge.css'; + +interface BadgeProps { + children: React.ReactNode; + variant?: 'default' | 'green' | 'yellow' | 'red' | 'blue'; +} + +export function Badge({ children, variant = 'default' }: BadgeProps) { + return {children}; +} diff --git a/ui/src/components/DataTable.css b/ui/src/components/DataTable.css new file mode 100644 index 0000000..6042ce0 --- /dev/null +++ b/ui/src/components/DataTable.css @@ -0,0 +1,50 @@ +.data-table-wrap { + overflow-x: auto; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-surface); +} + +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 0.9em; +} + +.data-table th { + text-align: left; + padding: 10px 14px; + font-size: 0.8em; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; + border-bottom: 1px solid var(--border); + background: var(--bg-elevated); + white-space: nowrap; +} + +.data-table td { + padding: 10px 14px; + border-bottom: 1px solid var(--border); + color: var(--text); + vertical-align: middle; +} + +.data-table tbody tr:last-child td { + border-bottom: none; +} + +.data-table tbody tr:hover { + background: var(--bg-elevated); +} + +.data-table tbody tr.clickable { + cursor: pointer; +} + +.data-table-empty { + text-align: center; + color: var(--text-muted); + padding: 32px 14px !important; +} diff --git a/ui/src/components/DataTable.tsx b/ui/src/components/DataTable.tsx new file mode 100644 index 0000000..02f1702 --- /dev/null +++ b/ui/src/components/DataTable.tsx @@ -0,0 +1,54 @@ +import './DataTable.css'; + +interface Column { + key: string; + header: string; + render: (item: T) => React.ReactNode; + width?: string; +} + +interface DataTableProps { + columns: Column[]; + data: T[]; + emptyMessage?: string; + onRowClick?: (item: T) => void; +} + +export function DataTable({ columns, data, emptyMessage = 'No data', onRowClick }: DataTableProps) { + return ( +
+ + + + {columns.map(col => ( + + ))} + + + + {data.length === 0 ? ( + + + + ) : ( + data.map((item, i) => ( + onRowClick(item) : undefined} + className={onRowClick ? 'clickable' : ''} + > + {columns.map(col => ( + + ))} + + )) + )} + +
+ {col.header} +
+ {emptyMessage} +
{col.render(item)}
+
+ ); +} diff --git a/ui/src/components/StatsCard.css b/ui/src/components/StatsCard.css new file mode 100644 index 0000000..54f21b3 --- /dev/null +++ b/ui/src/components/StatsCard.css @@ -0,0 +1,29 @@ +.stats-card { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 20px; + min-width: 160px; +} + +.stats-label { + font-size: 0.8em; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 6px; +} + +.stats-value { + font-size: 1.8em; + font-weight: 700; + color: var(--text-bright); + font-family: var(--font-mono); + line-height: 1.2; +} + +.stats-sub { + font-size: 0.8em; + color: var(--text-muted); + margin-top: 4px; +} diff --git a/ui/src/components/StatsCard.tsx b/ui/src/components/StatsCard.tsx new file mode 100644 index 0000000..5e73f75 --- /dev/null +++ b/ui/src/components/StatsCard.tsx @@ -0,0 +1,17 @@ +import './StatsCard.css'; + +interface StatsCardProps { + label: string; + value: string | number; + sub?: string; +} + +export function StatsCard({ label, value, sub }: StatsCardProps) { + return ( +
+
{label}
+
{value}
+ {sub &&
{sub}
} +
+ ); +} diff --git a/ui/src/components/format.ts b/ui/src/components/format.ts new file mode 100644 index 0000000..88e7e23 --- /dev/null +++ b/ui/src/components/format.ts @@ -0,0 +1,29 @@ +export function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + const val = bytes / Math.pow(1024, i); + return `${val.toFixed(i === 0 ? 0 : 1)} ${units[i]}`; +} + +export function formatNumber(n: number): string { + return n.toLocaleString(); +} + +export function timeAgo(dateStr: string): string { + const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000); + if (seconds < 60) return `${seconds}s ago`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + +export function truncateHash(hash: string, len = 12): string { + if (hash.startsWith('sha256:')) { + return hash.slice(0, 7 + len); + } + return hash.slice(0, len); +} diff --git a/ui/src/index.css b/ui/src/index.css new file mode 100644 index 0000000..b084ab5 --- /dev/null +++ b/ui/src/index.css @@ -0,0 +1,48 @@ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +:root { + --bg: #0b0e14; + --bg-surface: #111620; + --bg-elevated: #1a2030; + --border: #252d3d; + --text: #c5cdd9; + --text-muted: #6b7a90; + --text-bright: #e8ecf2; + --accent: #3b82f6; + --accent-hover: #2563eb; + --green: #22c55e; + --yellow: #eab308; + --red: #ef4444; + --orange: #f97316; + --radius: 8px; + --font-mono: 'JetBrains Mono', 'Fira Code', 'SF Mono', Menlo, monospace; + --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; +} + +body { + font-family: var(--font-sans); + background: var(--bg); + color: var(--text); + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +a { + color: var(--accent); + text-decoration: none; +} +a:hover { + color: var(--accent-hover); + text-decoration: underline; +} + +code, .mono { + font-family: var(--font-mono); + font-size: 0.85em; +} diff --git a/ui/src/main.tsx b/ui/src/main.tsx new file mode 100644 index 0000000..8f6b53a --- /dev/null +++ b/ui/src/main.tsx @@ -0,0 +1,13 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { BrowserRouter } from 'react-router-dom'; +import { App } from './App'; +import './index.css'; + +createRoot(document.getElementById('root')!).render( + + + + + , +); diff --git a/ui/src/pages/Dashboard.css b/ui/src/pages/Dashboard.css new file mode 100644 index 0000000..e68cdbb --- /dev/null +++ b/ui/src/pages/Dashboard.css @@ -0,0 +1,47 @@ +.stats-row { + display: flex; + gap: 16px; + margin-bottom: 24px; + flex-wrap: wrap; +} + +.health-row { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 28px; + padding: 12px 16px; + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius); +} + +.health-label { + font-size: 0.85em; + font-weight: 600; + color: var(--text-muted); + margin-right: 4px; +} + +.section-title { + font-size: 1.1em; + font-weight: 600; + color: var(--text-bright); + margin-bottom: 12px; + margin-top: 8px; +} + +.error-banner { + background: rgba(239, 68, 68, 0.1); + border: 1px solid var(--red); + color: var(--red); + padding: 12px 16px; + border-radius: var(--radius); + font-size: 0.9em; +} + +.loading { + color: var(--text-muted); + font-size: 0.9em; + padding: 32px 0; +} diff --git a/ui/src/pages/Dashboard.tsx b/ui/src/pages/Dashboard.tsx new file mode 100644 index 0000000..81825c8 --- /dev/null +++ b/ui/src/pages/Dashboard.tsx @@ -0,0 +1,92 @@ +import { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { api } from '../api/client'; +import type { OverviewStats, RemoteStatRow, HealthStatus } from '../api/types'; +import { StatsCard } from '../components/StatsCard'; +import { Badge } from '../components/Badge'; +import { DataTable } from '../components/DataTable'; +import { formatBytes, formatNumber } from '../components/format'; +import './Dashboard.css'; + +export function Dashboard() { + const [stats, setStats] = useState(null); + const [topRemotes, setTopRemotes] = useState([]); + const [health, setHealth] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + Promise.all([api.stats(), api.topRemotes(), api.health()]) + .then(([s, tr, h]) => { + setStats(s); + setTopRemotes(tr || []); + setHealth(h); + }) + .catch(e => setError(e.message)); + }, []); + + if (error) return
{error}
; + if (!stats) return
Loading...
; + + return ( +
+

Dashboard

+ +
+ + + + +
+ + {health && ( +
+ Services + + postgres: {health.postgres} + + + redis: {health.redis} + + + s3: {health.s3} + +
+ )} + +

Top Remotes by Size

+ {r.name}, + }, + { + key: 'objects', + header: 'Objects', + render: (r: RemoteStatRow) => formatNumber(r.object_count), + width: '120px', + }, + { + key: 'size', + header: 'Size', + render: (r: RemoteStatRow) => formatBytes(r.total_bytes), + width: '120px', + }, + { + key: 'requests', + header: 'Requests (30d)', + render: (r: RemoteStatRow) => formatNumber(r.requests_30d), + width: '140px', + }, + ]} + data={topRemotes} + emptyMessage="No remotes configured yet" + /> +
+ ); +} diff --git a/ui/src/pages/Objects.css b/ui/src/pages/Objects.css new file mode 100644 index 0000000..31334d9 --- /dev/null +++ b/ui/src/pages/Objects.css @@ -0,0 +1,64 @@ +.objects-toolbar { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; +} + +.obj-path { + font-size: 0.85em; + word-break: break-all; +} + +.hash-cell { + font-size: 0.8em; + color: var(--text-muted); +} + +.btn-evict { + background: transparent; + border: 1px solid var(--red); + color: var(--red); + padding: 3px 10px; + border-radius: 4px; + font-size: 0.8em; + cursor: pointer; + transition: all 0.15s; +} + +.btn-evict:hover { + background: rgba(239, 68, 68, 0.15); +} + +.pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 16px; + margin-top: 16px; +} + +.page-info { + font-size: 0.85em; + color: var(--text-muted); + font-family: var(--font-mono); +} + +.btn-sm { + padding: 5px 12px; + font-size: 0.85em; + background: var(--bg-surface); + border: 1px solid var(--border); + color: var(--text); + border-radius: var(--radius); + cursor: pointer; +} + +.btn-sm:hover:not(:disabled) { + background: var(--bg-elevated); +} + +.btn-sm:disabled { + opacity: 0.4; + cursor: default; +} diff --git a/ui/src/pages/Objects.tsx b/ui/src/pages/Objects.tsx new file mode 100644 index 0000000..2e9ee01 --- /dev/null +++ b/ui/src/pages/Objects.tsx @@ -0,0 +1,131 @@ +import { useEffect, useState, useCallback } from 'react'; +import { useParams, Link } from 'react-router-dom'; +import { api } from '../api/client'; +import type { Artifact } from '../api/types'; +import { DataTable } from '../components/DataTable'; +import { formatBytes, timeAgo, truncateHash } from '../components/format'; +import './Objects.css'; + +export function Objects() { + const { name } = useParams<{ name: string }>(); + const [artifacts, setArtifacts] = useState([]); + const [page, setPage] = useState(1); + const [loading, setLoading] = useState(true); + const [filter, setFilter] = useState(''); + + const load = useCallback(() => { + if (!name) return; + setLoading(true); + api.listObjects(name, page, 50) + .then(a => setArtifacts(a || [])) + .finally(() => setLoading(false)); + }, [name, page]); + + useEffect(() => { load(); }, [load]); + + const handleEvict = async (path: string) => { + if (!name || !confirm(`Evict ${path}?`)) return; + await api.evictObject(name, path); + load(); + }; + + const filtered = filter + ? artifacts.filter(a => a.path.toLowerCase().includes(filter.toLowerCase())) + : artifacts; + + return ( +
+
+ ← {name} +

Cached Objects

+
+ +
+ setFilter(e.target.value)} + /> + {filtered.length} objects +
+ + {loading ? ( +
Loading...
+ ) : ( + <> + {a.path}, + }, + { + key: 'size', + header: 'Size', + render: (a: Artifact) => formatBytes(a.size_bytes), + width: '100px', + }, + { + key: 'hash', + header: 'Hash', + render: (a: Artifact) => ( + + {truncateHash(a.content_hash)} + + ), + width: '160px', + }, + { + key: 'accessed', + header: 'Last Accessed', + render: (a: Artifact) => timeAgo(a.last_accessed_at), + width: '120px', + }, + { + key: 'hits', + header: 'Hits', + render: (a: Artifact) => a.access_count, + width: '70px', + }, + { + key: 'actions', + header: '', + render: (a: Artifact) => ( + + ), + width: '80px', + }, + ]} + data={filtered} + emptyMessage="No cached objects" + /> + +
+ + Page {page} + +
+ + )} +
+ ); +} diff --git a/ui/src/pages/Probe.css b/ui/src/pages/Probe.css new file mode 100644 index 0000000..36798bf --- /dev/null +++ b/ui/src/pages/Probe.css @@ -0,0 +1,141 @@ +.probe-description { + color: var(--text-muted); + font-size: 0.9em; + margin-bottom: 20px; +} + +.probe-form { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 20px; + display: flex; + flex-direction: column; + gap: 14px; + margin-bottom: 16px; +} + +.probe-row { + display: flex; + align-items: center; + gap: 12px; +} + +.probe-row label { + width: 70px; + font-size: 0.85em; + font-weight: 600; + color: var(--text-muted); + flex-shrink: 0; +} + +.probe-select { + flex: 1; + max-width: 350px; +} + +.probe-input { + flex: 1; +} + +.presets { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 16px; + flex-wrap: wrap; +} + +.presets-label { + font-size: 0.8em; + color: var(--text-muted); + font-weight: 600; +} + +.preset-btn { + background: var(--bg-surface); + border: 1px solid var(--border); + color: var(--accent); + padding: 3px 10px; + border-radius: 4px; + font-size: 0.8em; + font-family: var(--font-mono); + cursor: pointer; + transition: all 0.15s; +} + +.preset-btn:hover { + background: var(--bg-elevated); + border-color: var(--accent); +} + +.probe-result { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 20px; + margin-bottom: 24px; +} + +.probe-ok { + border-color: rgba(34, 197, 94, 0.3); +} + +.probe-err { + border-color: rgba(239, 68, 68, 0.3); +} + +.probe-result-header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 16px; +} + +.probe-duration { + font-family: var(--font-mono); + font-size: 0.85em; + color: var(--text-muted); + margin-left: auto; +} + +.probe-error { + color: var(--red); + font-family: var(--font-mono); + font-size: 0.9em; + background: rgba(239, 68, 68, 0.08); + padding: 10px; + border-radius: 4px; +} + +.probe-dl { + display: grid; + grid-template-columns: 130px 1fr; + gap: 6px 12px; + font-size: 0.9em; +} + +.probe-dl dt { + color: var(--text-muted); + font-weight: 500; +} + +.probe-dl dd { + color: var(--text); +} + +.probe-header-row { + display: contents; +} + +.probe-history { + margin-top: 8px; +} + +.probe-path-cell { + font-size: 0.8em; + max-width: 400px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/ui/src/pages/Probe.tsx b/ui/src/pages/Probe.tsx new file mode 100644 index 0000000..dbacba4 --- /dev/null +++ b/ui/src/pages/Probe.tsx @@ -0,0 +1,202 @@ +import { useEffect, useState } from 'react'; +import { api } from '../api/client'; +import type { Remote, ProbeResult } from '../api/types'; +import { Badge } from '../components/Badge'; +import { formatBytes } from '../components/format'; +import './Probe.css'; + +const PRESETS: Record = { + 'gitea-dl': [ + { remote: 'gitea-dl', path: 'gitea/1.23.7/gitea-1.23.7-linux-amd64' }, + { remote: 'gitea-dl', path: 'act_runner/0.2.11/act_runner-0.2.11-linux-amd64' }, + ], + 'github': [ + { remote: 'github', path: 'ducaale/xh/releases/download/v0.24.0/xh-v0.24.0-x86_64-unknown-linux-musl.tar.gz' }, + { remote: 'github', path: 'mikefarah/yq/releases/download/v4.45.4/yq_linux_amd64' }, + { remote: 'github', path: 'neovim/neovim-releases/releases/download/v0.11.2/nvim-linux-x86_64.tar.gz' }, + ], + 'hashicorp-releases': [ + { remote: 'hashicorp-releases', path: 'terraform/1.12.2/terraform_1.12.2_linux_amd64.zip' }, + ], + 'goproxy': [ + { remote: 'goproxy', path: 'golang.org/x/net/@v/list' }, + { remote: 'goproxy', path: 'golang.org/x/net/@v/v0.55.0.info' }, + ], +}; + +export function Probe() { + const [remotes, setRemotes] = useState([]); + const [remote, setRemote] = useState(''); + const [path, setPath] = useState(''); + const [loading, setLoading] = useState(false); + const [result, setResult] = useState(null); + const [history, setHistory] = useState<(ProbeResult & { remote: string; path: string })[]>([]); + + useEffect(() => { + api.listRemotes().then(r => { + setRemotes(r || []); + if (r?.length && !remote) setRemote(r[0].name); + }); + }, []); + + const runProbe = async () => { + if (!remote || !path) return; + setLoading(true); + setResult(null); + try { + const r = await api.probe(remote, path); + setResult(r); + setHistory(prev => [{ ...r, remote, path }, ...prev].slice(0, 20)); + } catch (e: unknown) { + setResult({ + status: 0, + source: '', + content_type: '', + size_bytes: 0, + headers: {}, + duration_ms: 0, + error: e instanceof Error ? e.message : String(e), + }); + } finally { + setLoading(false); + } + }; + + const applyPreset = (r: string, p: string) => { + setRemote(r); + setPath(p); + }; + + const remotePresets = PRESETS[remote] || []; + + return ( +
+

Test Remote

+

+ Probe a remote to test connectivity and caching. The file is fetched and cached but not sent to your browser. +

+ +
+
+ + +
+ +
+ + setPath(e.target.value)} + onKeyDown={e => e.key === 'Enter' && runProbe()} + /> +
+ + +
+ + {remotePresets.length > 0 && ( +
+ Quick tests: + {remotePresets.map((p, i) => ( + + ))} +
+ )} + + {result && ( +
+
+ + {result.status} + + + {result.source || 'error'} + + {result.duration_ms}ms +
+ + {result.error ? ( +
{result.error}
+ ) : ( +
+
Content-Type
+
{result.content_type}
+
Size
+
{formatBytes(result.size_bytes)} ({result.size_bytes.toLocaleString()} bytes)
+
Source
+
{result.source === 'cache' ? 'Served from cache (S3)' : 'Fetched from upstream'}
+
Duration
+
{result.duration_ms}ms
+ {result.headers && Object.entries(result.headers).map(([k, v]) => ( +
+
{k}
+
{v}
+
+ ))} +
+ )} +
+ )} + + {history.length > 0 && ( +
+

History

+ + + + + + + + + + + + + {history.map((h, i) => ( + applyPreset(h.remote, h.path)} + > + + + + + + + + ))} + +
RemotePathStatusSourceSizeTime
{h.remote}{h.path} + {h.status} + + + {h.source || 'err'} + + {formatBytes(h.size_bytes)}{h.duration_ms}ms
+
+ )} +
+ ); +} diff --git a/ui/src/pages/RemoteDetail.css b/ui/src/pages/RemoteDetail.css new file mode 100644 index 0000000..c4dbdd1 --- /dev/null +++ b/ui/src/pages/RemoteDetail.css @@ -0,0 +1,117 @@ +.detail-header { + margin-bottom: 20px; +} + +.back-link { + font-size: 0.85em; + color: var(--text-muted); + display: inline-block; + margin-bottom: 8px; +} + +.detail-badges { + display: flex; + gap: 8px; + margin-top: 4px; +} + +.detail-description { + color: var(--text-muted); + font-size: 0.95em; + margin-bottom: 24px; +} + +.detail-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; + margin-bottom: 24px; +} + +.detail-section { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 18px; +} + +.section-label { + font-size: 0.85em; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; + margin-bottom: 12px; +} + +.detail-dl { + display: grid; + grid-template-columns: 140px 1fr; + gap: 6px 12px; + font-size: 0.9em; +} + +.detail-dl dt { + color: var(--text-muted); + font-weight: 500; +} + +.detail-dl dd { + color: var(--text); +} + +.pattern-list { + list-style: none; + padding: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.pattern-list li { + font-size: 0.85em; + color: var(--text); + background: var(--bg-elevated); + padding: 2px 6px; + border-radius: 3px; + display: inline-block; + width: fit-content; +} + +.detail-actions { + display: flex; + gap: 10px; +} + +.btn { + display: inline-block; + padding: 8px 16px; + border-radius: var(--radius); + font-size: 0.9em; + font-weight: 500; + border: none; + cursor: pointer; + text-decoration: none; + transition: all 0.15s; +} + +.btn-primary { + background: var(--accent); + color: #fff; +} + +.btn-primary:hover { + background: var(--accent-hover); + text-decoration: none; + color: #fff; +} + +.btn-danger { + background: rgba(239, 68, 68, 0.15); + color: var(--red); + border: 1px solid var(--red); +} + +.btn-danger:hover { + background: rgba(239, 68, 68, 0.25); +} diff --git a/ui/src/pages/RemoteDetail.tsx b/ui/src/pages/RemoteDetail.tsx new file mode 100644 index 0000000..7a2bf95 --- /dev/null +++ b/ui/src/pages/RemoteDetail.tsx @@ -0,0 +1,129 @@ +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 RemoteDetail() { + const { name } = useParams<{ name: string }>(); + const [remote, setRemote] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (!name) return; + api.getRemote(name) + .then(setRemote) + .catch(e => setError(e.message)); + }, [name]); + + if (error) return
{error}
; + if (!remote) return
Loading...
; + + return ( +
+
+ ← Remotes +

{remote.name}

+
+ {remote.package_type} + {remote.managed_by && managed by {remote.managed_by}} +
+
+ + {remote.description && ( +

{remote.description}

+ )} + +
+
+

Configuration

+
+
Base URL
+
{remote.base_url}
+
Immutable TTL
+
{remote.immutable_ttl === 0 ? 'forever' : `${remote.immutable_ttl}s`}
+
Mutable TTL
+
{remote.mutable_ttl}s
+
Conditional Revalidation
+
{remote.check_mutable ? 'enabled' : 'disabled'}
+
Stale on Error
+
{remote.stale_on_error ? 'enabled' : 'disabled'}
+ {remote.releases_remote && ( + <> +
Releases Remote
+
+ + {remote.releases_remote} + +
+ + )} +
+
+ +
+

Access Control

+
+
Patterns
+
+ {remote.patterns?.length + ? + : none (proxy all)} +
+
Blocklist
+
+ {remote.blocklist?.length + ? + : none} +
+
+
+ +
+

Classification Overrides

+
+
Mutable
+
+ {remote.mutable_patterns?.length + ? + : provider defaults only} +
+
Immutable
+
+ {remote.immutable_patterns?.length + ? + : provider defaults only} +
+
+
+ + {remote.ban_tags_enabled && ( +
+

Tag Banning

+
+
Banned Tags
+
+
+
+ )} +
+ +
+ + Browse Objects + +
+
+ ); +} + +function PatternList({ patterns }: { patterns: string[] }) { + return ( +
    + {patterns.map((p, i) => ( +
  • {p}
  • + ))} +
+ ); +} diff --git a/ui/src/pages/Remotes.css b/ui/src/pages/Remotes.css new file mode 100644 index 0000000..24d1e40 --- /dev/null +++ b/ui/src/pages/Remotes.css @@ -0,0 +1,46 @@ +.remotes-toolbar { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; +} + +.search-input { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 8px 12px; + color: var(--text); + font-size: 0.9em; + outline: none; + width: 260px; +} + +.search-input:focus { + border-color: var(--accent); +} + +.type-select { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 8px 12px; + color: var(--text); + font-size: 0.9em; + outline: none; + cursor: pointer; +} + +.type-select:focus { + border-color: var(--accent); +} + +.result-count { + font-size: 0.85em; + color: var(--text-muted); + margin-left: auto; +} + +.text-muted { + color: var(--text-muted); +} diff --git a/ui/src/pages/Remotes.tsx b/ui/src/pages/Remotes.tsx new file mode 100644 index 0000000..77dcd4f --- /dev/null +++ b/ui/src/pages/Remotes.tsx @@ -0,0 +1,113 @@ +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 = { + docker: 'blue', + helm: 'green', + rpm: 'yellow', + pypi: 'blue', + npm: 'red', + generic: 'default', + alpine: 'green', + puppet: 'yellow', + terraform: 'blue', + goproxy: 'green', +}; + +export function Remotes() { + const navigate = useNavigate(); + const [remotes, setRemotes] = useState([]); + const [filter, setFilter] = useState(''); + const [typeFilter, setTypeFilter] = useState(''); + const [loading, setLoading] = useState(true); + + useEffect(() => { + api.listRemotes() + .then(r => setRemotes(r || [])) + .finally(() => setLoading(false)); + }, []); + + const types = [...new Set(remotes.map(r => r.package_type))].sort(); + + const filtered = remotes.filter(r => { + if (typeFilter && r.package_type !== typeFilter) return false; + if (filter && !r.name.toLowerCase().includes(filter.toLowerCase())) return false; + return true; + }); + + return ( +
+

Remotes

+ +
+ setFilter(e.target.value)} + /> + + {filtered.length} remotes +
+ + {loading ? ( +
Loading...
+ ) : ( + {r.name}, + }, + { + key: 'type', + header: 'Type', + render: (r: Remote) => ( + + {r.package_type} + + ), + width: '110px', + }, + { + key: 'description', + header: 'Description', + render: (r: Remote) => r.description || , + }, + { + key: 'ttl', + header: 'Mutable TTL', + render: (r: Remote) => {r.mutable_ttl}s, + width: '110px', + }, + { + key: 'managed', + header: 'Managed', + render: (r: Remote) => + r.managed_by ? {r.managed_by} : , + width: '100px', + }, + ]} + data={filtered} + emptyMessage="No remotes match" + onRowClick={(r) => navigate(`/remotes/${r.name}`)} + /> + )} +
+ ); +} diff --git a/ui/src/pages/Virtuals.css b/ui/src/pages/Virtuals.css new file mode 100644 index 0000000..e74a72d --- /dev/null +++ b/ui/src/pages/Virtuals.css @@ -0,0 +1,43 @@ +.member-count { + font-family: var(--font-mono); + font-size: 0.9em; + color: var(--text-muted); +} + +.virtual-detail-panel { + margin-top: 20px; + padding: 18px; + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius); +} + +.member-list { + list-style: none; + padding: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.member-list li { + display: flex; + align-items: center; + gap: 10px; + font-size: 0.9em; +} + +.member-priority { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: 50%; + background: var(--bg-elevated); + color: var(--text-muted); + font-size: 0.75em; + font-weight: 600; + font-family: var(--font-mono); + flex-shrink: 0; +} diff --git a/ui/src/pages/Virtuals.tsx b/ui/src/pages/Virtuals.tsx new file mode 100644 index 0000000..fa51f82 --- /dev/null +++ b/ui/src/pages/Virtuals.tsx @@ -0,0 +1,83 @@ +import { useEffect, useState } from 'react'; +import { api } from '../api/client'; +import type { Virtual } from '../api/types'; +import { Badge } from '../components/Badge'; +import { DataTable } from '../components/DataTable'; +import './Virtuals.css'; + +export function Virtuals() { + const [virtuals, setVirtuals] = useState([]); + const [loading, setLoading] = useState(true); + const [expanded, setExpanded] = useState(null); + + useEffect(() => { + api.listVirtuals() + .then(v => setVirtuals(v || [])) + .finally(() => setLoading(false)); + }, []); + + return ( +
+

Virtual Repositories

+ + {loading ? ( +
Loading...
+ ) : ( + {v.name}, + }, + { + key: 'type', + header: 'Type', + render: (v: Virtual) => {v.package_type}, + width: '110px', + }, + { + key: 'members', + header: 'Members', + render: (v: Virtual) => ( + {v.members?.length || 0} remotes + ), + width: '110px', + }, + { + key: 'description', + header: 'Description', + render: (v: Virtual) => v.description || , + }, + { + key: 'managed', + header: 'Managed', + render: (v: Virtual) => + v.managed_by ? {v.managed_by} : , + width: '100px', + }, + ]} + data={virtuals} + emptyMessage="No virtual repositories configured" + onRowClick={(v) => setExpanded(expanded === v.name ? null : v.name)} + /> + )} + + {expanded && ( +
+

Members of {expanded}

+
    + {virtuals + .find(v => v.name === expanded) + ?.members?.map((m, i) => ( +
  • + {i + 1} + {m} +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/ui/src/vite-env.d.ts b/ui/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/ui/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/ui/tsconfig.json b/ui/tsconfig.json new file mode 100644 index 0000000..39a405b --- /dev/null +++ b/ui/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/ui/vite.config.ts b/ui/vite.config.ts new file mode 100644 index 0000000..172618d --- /dev/null +++ b/ui/vite.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { + proxy: { + '/api': 'http://localhost:8000', + '/v2': 'http://localhost:8000', + '/health': 'http://localhost:8000', + '/metrics': 'http://localhost:8000', + }, + }, +})