Compare commits

...

3 Commits

Author SHA1 Message Date
unkinben 7b13644421 feat: virtual PyPI repos can merge local + remote members (#51)
ci/woodpecker/tag/docker Pipeline was successful
## Summary
- Virtual engine detects local members and generates indexes in-memory
- MemberIndex.RepoType drives correct URL prefix in merged output
- PyPI merger rewrites links to /api/v1/local/ or /api/v1/remote/ appropriately
- Includes local PyPI support (cherry-picked from #50)

## Test plan
- [x] Upload wheel to local PyPI → install from direct local URL
- [x] Create virtual with local + remote → install from virtual URL
- [x] Both paths produce correct absolute download URLs

Reviewed-on: #51
Co-authored-by: Ben Vincent <ben@unkin.net>
Co-committed-by: Ben Vincent <ben@unkin.net>
2026-06-23 22:20:05 +10:00
unkinben de96637122 feat: add local PyPI repository support (#50)
## Summary
- Upload Python wheels/sdists to local PyPI repos with filename validation
- PEP 503 simple index computed on-demand from stored files
- Package names normalized per PEP 503 (lowercase, hyphens)
- Overwrites rejected (409 Conflict)

## Test plan
- [x] Build wheel with `uv build` → upload → verify simple index HTML → `uv pip install` from local repo
- [x] Bad filename rejection (400)
- [x] Overwrite rejection (409)
- [x] Hash integrity verification on download

Reviewed-on: #50
Co-authored-by: Ben Vincent <ben@unkin.net>
Co-committed-by: Ben Vincent <ben@unkin.net>
2026-06-23 22:13:09 +10:00
benvin 1e91a5fb72 feat: add local repository type with repo_type field (#49)
ci/woodpecker/tag/docker Pipeline was successful
Introduces repo_type (remote/local) as a separate axis from package_type
so that any package type can be hosted locally. A terraform local repo
is package_type=terraform + repo_type=local.

- Remote model gains RepoType field (defaults to "remote")
- Database schema adds repo_type column with migration for existing DBs
- V1 proxy adds /api/v1/local/{name}/* route for serving local files
- V2 upload via PUT /api/v2/remotes/{name}/files/{ns}/{type}/{file}.zip
  validates filename matches terraform-provider-{type}_{ver}_{os}_{arch}.zip
  and returns 409 on duplicate (no overwrites)
- index.json and {version}.json are computed on-the-fly from uploaded zips
  rather than stored as separate files
- V2 create validates repo_type and requires base_url only for remotes

---------

Co-authored-by: Ben Vincent <ben@unkin.net>
Reviewed-on: #49
2026-06-22 23:52:20 +10:00
12 changed files with 832 additions and 37 deletions
+107 -2
View File
@@ -6,28 +6,38 @@ import (
"io"
"log/slog"
"net/http"
"regexp"
"strings"
"github.com/go-chi/chi/v5"
v2 "git.unkin.net/unkin/artifactapi/internal/api/v2"
"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/storage"
"git.unkin.net/unkin/artifactapi/internal/virtual"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
var semverRe = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9.]+)?$`)
type ProxyHandler struct {
engine *proxy.Engine
virtualEngine *virtual.Engine
db *database.DB
store *storage.S3
local *v2.LocalHandler
}
func NewProxyHandler(engine *proxy.Engine, virtualEngine *virtual.Engine, db *database.DB) *ProxyHandler {
return &ProxyHandler{engine: engine, virtualEngine: virtualEngine, db: db}
func NewProxyHandler(engine *proxy.Engine, virtualEngine *virtual.Engine, db *database.DB, store *storage.S3, local *v2.LocalHandler) *ProxyHandler {
return &ProxyHandler{engine: engine, virtualEngine: virtualEngine, db: db, store: store, local: local}
}
func (h *ProxyHandler) Routes() chi.Router {
r := chi.NewRouter()
r.Get("/remote/{remoteName}/*", h.handleProxy)
r.Get("/local/{localName}/*", h.handleLocal)
r.Get("/virtual/{virtualName}/*", h.handleVirtual)
return r
}
@@ -95,6 +105,101 @@ func (h *ProxyHandler) handleVirtual(w http.ResponseWriter, r *http.Request) {
w.Write(body)
}
func (h *ProxyHandler) handleLocal(w http.ResponseWriter, r *http.Request) {
localName := chi.URLParam(r, "localName")
path := chi.URLParam(r, "*")
remote, err := h.db.GetRemote(r.Context(), localName)
if err != nil {
http.Error(w, fmt.Sprintf("local %q not found", localName), http.StatusNotFound)
return
}
switch remote.PackageType {
case models.PackageTerraform:
if h.serveTerraformMirror(w, r, remote, path) {
return
}
case models.PackagePyPI:
if h.servePyPIMirror(w, r, remote, path) {
return
}
}
h.serveLocalFile(w, r, localName, path)
}
func (h *ProxyHandler) serveTerraformMirror(w http.ResponseWriter, r *http.Request, remote *models.Remote, path string) bool {
parts := strings.Split(path, "/")
if len(parts) < 3 {
return false
}
namespace, typeName := parts[0], parts[1]
tail := parts[2]
if tail == "index.json" {
h.local.ServeTerraformIndex(w, r, remote.Name, namespace, typeName)
return true
}
if strings.HasSuffix(tail, ".json") {
version := strings.TrimSuffix(tail, ".json")
if semverRe.MatchString(version) {
h.local.ServeTerraformVersionDoc(w, r, remote.Name, namespace, typeName, version)
return true
}
}
return false
}
func (h *ProxyHandler) servePyPIMirror(w http.ResponseWriter, r *http.Request, remote *models.Remote, path string) bool {
if path == "simple" || path == "simple/" {
h.local.ServePyPIIndex(w, r, remote.Name)
return true
}
if strings.HasPrefix(path, "simple/") {
pkg := strings.TrimPrefix(path, "simple/")
pkg = strings.TrimSuffix(pkg, "/")
if pkg != "" && !strings.Contains(pkg, "/") {
h.local.ServePyPIPackageIndex(w, r, remote.Name, pkg)
return true
}
}
return false
}
func (h *ProxyHandler) serveLocalFile(w http.ResponseWriter, r *http.Request, repoName, path string) {
file, err := h.db.GetLocalFile(r.Context(), repoName, path)
if err != nil {
slog.Error("local file lookup failed", "repo", repoName, "path", path, "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if file == nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
s3Key := storage.BlobKey(file.ContentHash[len("sha256:"):])
reader, info, err := h.store.Download(r.Context(), s3Key)
if err != nil {
slog.Error("local file download failed", "repo", repoName, "path", path, "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
defer reader.Close()
w.Header().Set("Content-Type", info.ContentType)
w.Header().Set("Content-Length", fmt.Sprintf("%d", info.Size))
w.Header().Set("X-Artifact-Source", "local")
w.WriteHeader(http.StatusOK)
io.Copy(w, reader)
}
func scheme(r *http.Request) string {
if r.TLS != nil {
return "https"
+467
View File
@@ -0,0 +1,467 @@
package v2
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"github.com/go-chi/chi/v5"
"git.unkin.net/unkin/artifactapi/internal/database"
"git.unkin.net/unkin/artifactapi/internal/storage"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
var pypiFileRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*\.(whl|tar\.gz|zip)$`)
var pypiNormalizeRe = regexp.MustCompile(`[-_.]+`)
func pypiNormalize(name string) string {
return strings.ToLower(pypiNormalizeRe.ReplaceAllString(name, "-"))
}
func pypiPackageFromWheel(filename string) string {
parts := strings.SplitN(filename, "-", 3)
if len(parts) < 2 {
return ""
}
return pypiNormalize(parts[0])
}
func pypiPackageFromSdist(filename string) string {
name := filename
for _, suffix := range []string{".tar.gz", ".zip"} {
if strings.HasSuffix(name, suffix) {
name = strings.TrimSuffix(name, suffix)
break
}
}
idx := strings.LastIndex(name, "-")
if idx <= 0 {
return ""
}
return pypiNormalize(name[:idx])
}
var providerZipRe = regexp.MustCompile(
`^terraform-provider-([a-zA-Z0-9_-]+)_([0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9.]+)?)_([a-z0-9]+)_([a-z0-9]+)\.zip$`,
)
type LocalHandler struct {
db *database.DB
store *storage.S3
cas *storage.CAS
}
func NewLocalHandler(db *database.DB, store *storage.S3) *LocalHandler {
return &LocalHandler{
db: db,
store: store,
cas: storage.NewCAS(store),
}
}
func (h *LocalHandler) Routes() chi.Router {
r := chi.NewRouter()
r.Put("/*", h.upload)
r.Get("/*", h.download)
r.Delete("/*", h.remove)
return r
}
func (h *LocalHandler) upload(w http.ResponseWriter, r *http.Request) {
repoName := chi.URLParam(r, "name")
filePath := chi.URLParam(r, "*")
if filePath == "" {
http.Error(w, "file path required", http.StatusBadRequest)
return
}
remote, err := h.db.GetRemote(r.Context(), repoName)
if err != nil {
http.Error(w, fmt.Sprintf("remote %q not found", repoName), http.StatusNotFound)
return
}
if remote.RepoType != models.RepoTypeLocal {
http.Error(w, "upload only allowed for local repository types", http.StatusBadRequest)
return
}
switch remote.PackageType {
case models.PackageTerraform:
h.uploadTerraformProvider(w, r, remote, filePath)
return
case models.PackagePyPI:
h.uploadPyPI(w, r, remote, filePath)
return
}
h.uploadGeneric(w, r, remote, filePath)
}
func (h *LocalHandler) uploadTerraformProvider(w http.ResponseWriter, r *http.Request, remote *models.Remote, filePath string) {
parts := strings.Split(filePath, "/")
if len(parts) != 3 {
http.Error(w, "path must be {namespace}/{type}/{filename}.zip", http.StatusBadRequest)
return
}
namespace, typeName, filename := parts[0], parts[1], parts[2]
m := providerZipRe.FindStringSubmatch(filename)
if m == nil {
http.Error(w, fmt.Sprintf(
"filename %q does not match terraform-provider-{type}_{version}_{os}_{arch}.zip",
filename,
), http.StatusBadRequest)
return
}
fileType, version, os, arch := m[1], m[2], m[3], m[4]
if fileType != typeName {
http.Error(w, fmt.Sprintf(
"provider type in filename %q does not match path type %q",
fileType, typeName,
), http.StatusBadRequest)
return
}
storagePath := fmt.Sprintf("%s/%s/%s", namespace, typeName, filename)
existing, err := h.db.GetLocalFile(r.Context(), remote.Name, storagePath)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if existing != nil {
http.Error(w, fmt.Sprintf(
"provider %s/%s version %s for %s_%s already exists; overwrites are not allowed",
namespace, typeName, version, os, arch,
), http.StatusConflict)
return
}
result, err := h.cas.Store(r.Context(), r.Body, "application/zip")
if err != nil {
http.Error(w, fmt.Sprintf("store failed: %v", err), http.StatusInternalServerError)
return
}
if err := h.db.UpsertBlob(r.Context(), result.ContentHash, result.S3Key, result.SizeBytes, "application/zip"); err != nil {
http.Error(w, fmt.Sprintf("record blob: %v", err), http.StatusInternalServerError)
return
}
if err := h.db.CreateLocalFile(r.Context(), remote.Name, storagePath, result.ContentHash); err != nil {
if errors.Is(err, database.ErrAlreadyExists) {
http.Error(w, fmt.Sprintf("file %q already exists; overwrites are not allowed", storagePath), http.StatusConflict)
return
}
http.Error(w, fmt.Sprintf("record file: %v", err), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusCreated, map[string]any{
"namespace": namespace,
"type": typeName,
"version": version,
"os": os,
"arch": arch,
"content_hash": result.ContentHash,
"size_bytes": result.SizeBytes,
})
}
func (h *LocalHandler) uploadGeneric(w http.ResponseWriter, r *http.Request, remote *models.Remote, filePath string) {
existing, err := h.db.GetLocalFile(r.Context(), remote.Name, filePath)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if existing != nil {
http.Error(w, fmt.Sprintf("file %q already exists; overwrites are not allowed", filePath), http.StatusConflict)
return
}
contentType := "application/octet-stream"
if ct := r.Header.Get("Content-Type"); ct != "" && ct != "application/octet-stream" {
contentType = ct
}
result, err := h.cas.Store(r.Context(), r.Body, contentType)
if err != nil {
http.Error(w, fmt.Sprintf("store failed: %v", err), http.StatusInternalServerError)
return
}
if err := h.db.UpsertBlob(r.Context(), result.ContentHash, result.S3Key, result.SizeBytes, contentType); err != nil {
http.Error(w, fmt.Sprintf("record blob: %v", err), http.StatusInternalServerError)
return
}
if err := h.db.CreateLocalFile(r.Context(), remote.Name, filePath, result.ContentHash); err != nil {
if errors.Is(err, database.ErrAlreadyExists) {
http.Error(w, fmt.Sprintf("file %q already exists; overwrites are not allowed", filePath), http.StatusConflict)
return
}
http.Error(w, fmt.Sprintf("record file: %v", err), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusCreated, map[string]any{
"path": filePath,
"content_hash": result.ContentHash,
"size_bytes": result.SizeBytes,
})
}
func (h *LocalHandler) uploadPyPI(w http.ResponseWriter, r *http.Request, remote *models.Remote, filePath string) {
filename := filePath
if idx := strings.LastIndex(filePath, "/"); idx >= 0 {
filename = filePath[idx+1:]
}
if !pypiFileRe.MatchString(filename) {
http.Error(w, fmt.Sprintf("filename %q must be a .whl, .tar.gz, or .zip file", filename), http.StatusBadRequest)
return
}
var pkgName string
if strings.HasSuffix(filename, ".whl") {
pkgName = pypiPackageFromWheel(filename)
} else {
pkgName = pypiPackageFromSdist(filename)
}
if pkgName == "" {
http.Error(w, fmt.Sprintf("cannot parse package name from %q", filename), http.StatusBadRequest)
return
}
storagePath := pkgName + "/" + filename
existing, err := h.db.GetLocalFile(r.Context(), remote.Name, storagePath)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if existing != nil {
http.Error(w, fmt.Sprintf("file %q already exists; overwrites are not allowed", storagePath), http.StatusConflict)
return
}
contentType := "application/zip"
if strings.HasSuffix(filename, ".tar.gz") {
contentType = "application/gzip"
}
result, err := h.cas.Store(r.Context(), r.Body, contentType)
if err != nil {
http.Error(w, fmt.Sprintf("store failed: %v", err), http.StatusInternalServerError)
return
}
if err := h.db.UpsertBlob(r.Context(), result.ContentHash, result.S3Key, result.SizeBytes, contentType); err != nil {
http.Error(w, fmt.Sprintf("record blob: %v", err), http.StatusInternalServerError)
return
}
if err := h.db.CreateLocalFile(r.Context(), remote.Name, storagePath, result.ContentHash); err != nil {
if errors.Is(err, database.ErrAlreadyExists) {
http.Error(w, fmt.Sprintf("file %q already exists; overwrites are not allowed", storagePath), http.StatusConflict)
return
}
http.Error(w, fmt.Sprintf("record file: %v", err), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusCreated, map[string]any{
"package": pkgName,
"filename": filename,
"content_hash": result.ContentHash,
"size_bytes": result.SizeBytes,
})
}
func (h *LocalHandler) ServePyPIIndex(w http.ResponseWriter, r *http.Request, repoName string) {
packages, err := h.db.ListLocalFilePackages(r.Context(), repoName)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
var b strings.Builder
b.WriteString("<!DOCTYPE html>\n<html><body>\n")
for _, pkg := range packages {
fmt.Fprintf(&b, "<a href=\"%s/\">%s</a>\n", pkg, pkg)
}
b.WriteString("</body></html>\n")
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
io.WriteString(w, b.String())
}
func (h *LocalHandler) ServePyPIPackageIndex(w http.ResponseWriter, r *http.Request, repoName, packageName string) {
normalized := pypiNormalize(packageName)
prefix := normalized + "/"
files, err := h.db.ListLocalFilesByPrefix(r.Context(), repoName, prefix)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if len(files) == 0 {
http.Error(w, "not found", http.StatusNotFound)
return
}
var b strings.Builder
b.WriteString("<!DOCTYPE html>\n<html><body>\n")
for _, f := range files {
filename := strings.TrimPrefix(f.FilePath, normalized+"/")
hash := strings.TrimPrefix(f.ContentHash, "sha256:")
fmt.Fprintf(&b, "<a href=\"../../%s/%s#sha256=%s\">%s</a>\n",
normalized, filename, hash, filename)
}
b.WriteString("</body></html>\n")
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
io.WriteString(w, b.String())
}
func (h *LocalHandler) GeneratePyPIPackageHTML(ctx context.Context, repoName, packageName string) ([]byte, error) {
normalized := pypiNormalize(packageName)
prefix := normalized + "/"
files, err := h.db.ListLocalFilesByPrefix(ctx, repoName, prefix)
if err != nil {
return nil, err
}
var b strings.Builder
b.WriteString("<!DOCTYPE html>\n<html><body>\n")
for _, f := range files {
filename := strings.TrimPrefix(f.FilePath, normalized+"/")
hash := strings.TrimPrefix(f.ContentHash, "sha256:")
fmt.Fprintf(&b, "<a href=\"%s/%s#sha256=%s\">%s</a>\n",
normalized, filename, hash, filename)
}
b.WriteString("</body></html>\n")
return []byte(b.String()), nil
}
func (h *LocalHandler) download(w http.ResponseWriter, r *http.Request) {
repoName := chi.URLParam(r, "name")
filePath := chi.URLParam(r, "*")
file, err := h.db.GetLocalFile(r.Context(), repoName, filePath)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if file == nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
s3Key := storage.BlobKey(file.ContentHash[len("sha256:"):])
reader, info, err := h.store.Download(r.Context(), s3Key)
if err != nil {
http.Error(w, fmt.Sprintf("download failed: %v", err), http.StatusInternalServerError)
return
}
defer reader.Close()
w.Header().Set("Content-Type", info.ContentType)
w.Header().Set("Content-Length", fmt.Sprintf("%d", info.Size))
w.WriteHeader(http.StatusOK)
io.Copy(w, reader)
}
func (h *LocalHandler) remove(w http.ResponseWriter, r *http.Request) {
repoName := chi.URLParam(r, "name")
filePath := chi.URLParam(r, "*")
if err := h.db.DeleteLocalFile(r.Context(), repoName, filePath); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
type terraformIndex struct {
Versions map[string]json.RawMessage `json:"versions"`
}
type terraformVersionDoc struct {
Archives map[string]terraformArchive `json:"archives"`
}
type terraformArchive struct {
URL string `json:"url"`
Hashes []string `json:"hashes,omitempty"`
}
func (h *LocalHandler) ServeTerraformIndex(w http.ResponseWriter, r *http.Request, repoName, namespace, typeName string) {
prefix := fmt.Sprintf("%s/%s/", namespace, typeName)
files, err := h.db.ListLocalFilesByPrefix(r.Context(), repoName, prefix)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
versions := map[string]json.RawMessage{}
for _, f := range files {
filename := strings.TrimPrefix(f.FilePath, prefix)
m := providerZipRe.FindStringSubmatch(filename)
if m == nil {
continue
}
versions[m[2]] = json.RawMessage(`{}`)
}
if len(versions) == 0 {
http.Error(w, "not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(terraformIndex{Versions: versions})
}
func (h *LocalHandler) ServeTerraformVersionDoc(w http.ResponseWriter, r *http.Request, repoName, namespace, typeName, version string) {
prefix := fmt.Sprintf("%s/%s/terraform-provider-%s_%s_", namespace, typeName, typeName, version)
files, err := h.db.ListLocalFilesByPrefix(r.Context(), repoName, prefix)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
archives := map[string]terraformArchive{}
for _, f := range files {
filename := strings.TrimPrefix(f.FilePath, fmt.Sprintf("%s/%s/", namespace, typeName))
m := providerZipRe.FindStringSubmatch(filename)
if m == nil || m[2] != version {
continue
}
platform := m[3] + "_" + m[4]
archive := terraformArchive{URL: filename}
if f.ContentHash != "" {
archive.Hashes = []string{"zh:" + strings.TrimPrefix(f.ContentHash, "sha256:")}
}
archives[platform] = archive
}
if len(archives) == 0 {
http.Error(w, "not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(terraformVersionDoc{Archives: archives})
}
+11
View File
@@ -58,6 +58,17 @@ func (h *RemotesHandler) create(w http.ResponseWriter, r *http.Request) {
http.Error(w, fmt.Sprintf("invalid package type: %q", remote.PackageType), http.StatusBadRequest)
return
}
if remote.RepoType == "" {
remote.RepoType = models.RepoTypeRemote
}
if !remote.RepoType.Valid() {
http.Error(w, fmt.Sprintf("invalid repo type: %q", remote.RepoType), http.StatusBadRequest)
return
}
if remote.RepoType == models.RepoTypeRemote && remote.BaseURL == "" {
http.Error(w, "base_url is required for remote repositories", http.StatusBadRequest)
return
}
if err := h.db.CreateRemote(r.Context(), &remote); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
+128
View File
@@ -0,0 +1,128 @@
package database
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
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"`
}
var ErrAlreadyExists = fmt.Errorf("file already exists")
func (db *DB) CreateLocalFile(ctx context.Context, repoName, filePath, contentHash string) error {
_, err := db.Pool.Exec(ctx, `
INSERT INTO local_files (repo_name, file_path, content_hash)
VALUES ($1, $2, $3)
`, repoName, filePath, contentHash)
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return ErrAlreadyExists
}
return err
}
return nil
}
func (db *DB) GetLocalFile(ctx context.Context, repoName, filePath string) (*LocalFile, error) {
row := db.Pool.QueryRow(ctx, `
SELECT id, repo_name, file_path, content_hash, created_at
FROM local_files
WHERE repo_name = $1 AND file_path = $2
`, repoName, filePath)
var f LocalFile
if err := row.Scan(&f.ID, &f.RepoName, &f.FilePath, &f.ContentHash, &f.CreatedAt); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &f, nil
}
func (db *DB) ListLocalFiles(ctx context.Context, repoName string, limit, offset int) ([]LocalFile, error) {
rows, err := db.Pool.Query(ctx, `
SELECT id, repo_name, file_path, content_hash, created_at
FROM local_files
WHERE repo_name = $1
ORDER BY file_path
LIMIT $2 OFFSET $3
`, repoName, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var files []LocalFile
for rows.Next() {
var f LocalFile
if err := rows.Scan(&f.ID, &f.RepoName, &f.FilePath, &f.ContentHash, &f.CreatedAt); err != nil {
return nil, err
}
files = append(files, f)
}
return files, rows.Err()
}
func (db *DB) ListLocalFilesByPrefix(ctx context.Context, repoName, prefix string) ([]LocalFile, error) {
rows, err := db.Pool.Query(ctx, `
SELECT id, repo_name, file_path, content_hash, created_at
FROM local_files
WHERE repo_name = $1 AND file_path LIKE $2
ORDER BY file_path
`, repoName, prefix+"%")
if err != nil {
return nil, err
}
defer rows.Close()
var files []LocalFile
for rows.Next() {
var f LocalFile
if err := rows.Scan(&f.ID, &f.RepoName, &f.FilePath, &f.ContentHash, &f.CreatedAt); err != nil {
return nil, err
}
files = append(files, f)
}
return files, rows.Err()
}
func (db *DB) ListLocalFilePackages(ctx context.Context, repoName string) ([]string, error) {
rows, err := db.Pool.Query(ctx, `
SELECT DISTINCT split_part(file_path, '/', 1)
FROM local_files
WHERE repo_name = $1
ORDER BY 1
`, repoName)
if err != nil {
return nil, err
}
defer rows.Close()
var packages []string
for rows.Next() {
var pkg string
if err := rows.Scan(&pkg); err != nil {
return nil, err
}
packages = append(packages, pkg)
}
return packages, rows.Err()
}
func (db *DB) DeleteLocalFile(ctx context.Context, repoName, filePath string) error {
_, err := db.Pool.Exec(ctx, `DELETE FROM local_files WHERE repo_name = $1 AND file_path = $2`, repoName, filePath)
return err
}
+4 -1
View File
@@ -42,7 +42,8 @@ func (db *DB) migrate() error {
CREATE TABLE IF NOT EXISTS remotes (
name TEXT PRIMARY KEY,
package_type TEXT NOT NULL,
base_url TEXT NOT NULL,
repo_type TEXT DEFAULT 'remote',
base_url TEXT NOT NULL DEFAULT '',
description TEXT DEFAULT '',
username TEXT DEFAULT '',
password TEXT DEFAULT '',
@@ -121,6 +122,8 @@ func (db *DB) migrate() error {
);
CREATE INDEX IF NOT EXISTS idx_access_log_remote_time ON access_log(remote_name, created_at);
ALTER TABLE remotes ADD COLUMN IF NOT EXISTS repo_type TEXT DEFAULT 'remote';
`)
return err
}
+12 -12
View File
@@ -6,7 +6,7 @@ import (
"git.unkin.net/unkin/artifactapi/pkg/models"
)
const remoteCols = `name, package_type, base_url, description, username, password,
const remoteCols = `name, package_type, repo_type, base_url, description, username, password,
immutable_ttl, mutable_ttl, check_mutable,
patterns, blocklist, mutable_patterns, immutable_patterns,
ban_tags_enabled, ban_tags,
@@ -15,7 +15,7 @@ const remoteCols = `name, package_type, base_url, description, username, passwor
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.Name, &r.PackageType, &r.RepoType, &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,
@@ -54,15 +54,15 @@ func (db *DB) ListRemotes(ctx context.Context) ([]models.Remote, error) {
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,
name, package_type, repo_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)
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21)
`,
r.Name, r.PackageType, r.BaseURL, r.Description, r.Username, r.Password,
r.Name, r.PackageType, r.RepoType, 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,
@@ -75,15 +75,15 @@ func (db *DB) CreateRemote(ctx context.Context, r *models.Remote) error {
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()
package_type=$2, repo_type=$3, base_url=$4, description=$5, username=$6, password=$7,
immutable_ttl=$8, mutable_ttl=$9, check_mutable=$10,
patterns=$11, blocklist=$12, mutable_patterns=$13, immutable_patterns=$14,
ban_tags_enabled=$15, ban_tags=$16,
quarantine_enabled=$17, quarantine_days=$18, stale_on_error=$19,
releases_remote=$20, managed_by=$21, updated_at=NOW()
WHERE name=$1
`,
r.Name, r.PackageType, r.BaseURL, r.Description, r.Username, r.Password,
r.Name, r.PackageType, r.RepoType, 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,
+26 -17
View File
@@ -34,14 +34,15 @@ import (
)
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
cfg *config.Config
router chi.Router
db *database.DB
cache *cache.Redis
store *storage.S3
engine *proxy.Engine
virtEngine *virtual.Engine
localHandler *v2.LocalHandler
gc *gc.Collector
}
func New(cfg *config.Config) (*Server, error) {
@@ -61,17 +62,19 @@ func New(cfg *config.Config) (*Server, error) {
}
engine := proxy.NewEngine(db, redis, s3)
virtEngine := virtual.NewEngine(db, engine)
localHandler := v2.NewLocalHandler(db, s3)
virtEngine := virtual.NewEngine(db, engine, localHandler)
collector := gc.New(db, s3, 1*time.Hour)
s := &Server{
cfg: cfg,
db: db,
cache: redis,
store: s3,
engine: engine,
virtEngine: virtEngine,
gc: collector,
cfg: cfg,
db: db,
cache: redis,
store: s3,
engine: engine,
virtEngine: virtEngine,
localHandler: localHandler,
gc: collector,
}
s.router = s.routes()
@@ -91,7 +94,7 @@ func (s *Server) routes() chi.Router {
r.Get("/health", s.handleHealth)
r.Get("/", s.handleRoot)
proxyHandler := v1.NewProxyHandler(s.engine, s.virtEngine, s.db)
proxyHandler := v1.NewProxyHandler(s.engine, s.virtEngine, s.db, s.store, s.localHandler)
r.Mount("/api/v1", proxyHandler.Routes())
remotesHandler := v2.NewRemotesHandler(s.db)
@@ -114,6 +117,12 @@ func (s *Server) routes() chi.Router {
r.Get("/", objHandler.Routes().ServeHTTP)
r.Delete("/*", objHandler.Routes().ServeHTTP)
})
r.Route("/remotes/{name}/files", func(r chi.Router) {
r.Put("/*", s.localHandler.Routes().ServeHTTP)
r.Get("/*", s.localHandler.Routes().ServeHTTP)
r.Delete("/*", s.localHandler.Routes().ServeHTTP)
})
})
return r
+36 -3
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"io"
"log/slog"
"strings"
"sync"
"git.unkin.net/unkin/artifactapi/internal/database"
@@ -13,13 +14,18 @@ import (
"git.unkin.net/unkin/artifactapi/pkg/models"
)
type LocalIndexGenerator interface {
GeneratePyPIPackageHTML(ctx context.Context, repoName, packageName string) ([]byte, error)
}
type Engine struct {
db *database.DB
proxyEngine *proxy.Engine
localGen LocalIndexGenerator
}
func NewEngine(db *database.DB, proxyEngine *proxy.Engine) *Engine {
return &Engine{db: db, proxyEngine: proxyEngine}
func NewEngine(db *database.DB, proxyEngine *proxy.Engine, localGen LocalIndexGenerator) *Engine {
return &Engine{db: db, proxyEngine: proxyEngine, localGen: localGen}
}
func (e *Engine) Fetch(ctx context.Context, virt models.Virtual, path string, proxyBaseURL string) ([]byte, string, error) {
@@ -73,6 +79,16 @@ func (e *Engine) fetchMemberIndexes(ctx context.Context, virt models.Virtual, pa
return
}
if remote.RepoType == models.RepoTypeLocal {
body, err := e.fetchLocalIndex(ctx, *remote, virt.PackageType, path)
if err != nil {
results[idx] = result{err: fmt.Errorf("local index %q: %w", name, err)}
return
}
results[idx] = result{index: MemberIndex{RemoteName: name, RepoType: remote.RepoType, Body: body}}
return
}
prov, err := provider.Get(remote.PackageType)
if err != nil {
results[idx] = result{err: fmt.Errorf("provider %q: %w", remote.PackageType, err)}
@@ -92,7 +108,7 @@ func (e *Engine) fetchMemberIndexes(ctx context.Context, virt models.Virtual, pa
return
}
results[idx] = result{index: MemberIndex{RemoteName: name, Body: body}}
results[idx] = result{index: MemberIndex{RemoteName: name, RepoType: remote.RepoType, Body: body}}
}(i, memberName)
}
@@ -109,3 +125,20 @@ func (e *Engine) fetchMemberIndexes(ctx context.Context, virt models.Virtual, pa
return members, nil
}
func (e *Engine) fetchLocalIndex(ctx context.Context, remote models.Remote, packageType models.PackageType, path string) ([]byte, error) {
switch packageType {
case models.PackagePyPI:
if e.localGen == nil {
return nil, fmt.Errorf("no local index generator configured")
}
parts := strings.SplitN(strings.TrimPrefix(path, "simple/"), "/", 2)
pkgName := strings.TrimSuffix(parts[0], "/")
if pkgName == "" {
return nil, fmt.Errorf("cannot determine package name from path %q", path)
}
return e.localGen.GeneratePyPIPackageHTML(ctx, remote.Name, pkgName)
default:
return nil, fmt.Errorf("local index generation not supported for %q", packageType)
}
}
+1
View File
@@ -8,6 +8,7 @@ import (
type MemberIndex struct {
RemoteName string
RepoType models.RepoType
Body []byte
}
+6 -1
View File
@@ -36,8 +36,13 @@ func (m *PyPIMerger) MergeIndexes(members []MemberIndex, proxyBaseURL string) ([
}
if proxyBaseURL != "" && href != "" {
href = fmt.Sprintf("%s/api/v1/remote/%s/%s",
routePrefix := "remote"
if member.RepoType == "local" {
routePrefix = "local"
}
href = fmt.Sprintf("%s/api/v1/%s/%s/%s",
strings.TrimRight(proxyBaseURL, "/"),
routePrefix,
member.RemoteName,
strings.TrimLeft(href, "/"))
}
+33 -1
View File
@@ -1,10 +1,42 @@
package models
import "time"
import (
"fmt"
"time"
)
type RepoType string
const (
RepoTypeRemote RepoType = "remote"
RepoTypeLocal RepoType = "local"
)
var validRepoTypes = map[RepoType]bool{
RepoTypeRemote: true,
RepoTypeLocal: true,
}
func (r RepoType) Valid() bool {
return validRepoTypes[r]
}
func (r RepoType) String() string {
return string(r)
}
func ParseRepoType(s string) (RepoType, error) {
rt := RepoType(s)
if !rt.Valid() {
return "", fmt.Errorf("unknown repo type: %q", s)
}
return rt, nil
}
type Remote struct {
Name string `json:"name"`
PackageType PackageType `json:"package_type"`
RepoType RepoType `json:"repo_type"`
BaseURL string `json:"base_url"`
Description string `json:"description,omitempty"`
Username string `json:"-"`
+1
View File
@@ -1,6 +1,7 @@
export interface Remote {
name: string;
package_type: string;
repo_type: string;
base_url: string;
description: string;
username?: string;