Feat/v3 go rewrite (#47)
ci/woodpecker/tag/docker Pipeline was successful

Complete rewrite of ArtifactAPI from Python/FastAPI to Go as a single binary.

Core engine:
- 10 package providers: generic, docker, helm, pypi, npm, rpm, alpine,
  puppet, terraform, goproxy — each with built-in mutable patterns
- Content-addressable storage (SHA256 dedup across all remotes)
- Three-tier caching: Redis (TTL/locks) → S3/MinIO (blobs) → upstream
- Classifier with allowlist/blocklist per-remote (empty = allow all)
- Circuit breaker, conditional revalidation, stale-on-error
- Background garbage collection for orphaned blobs
- Access logging to PostgreSQL

API:
- v1 proxy endpoints (backwards compatible)
- v2 management API: CRUD remotes/virtuals, object browser, stats,
  health, SSE events, probe/test endpoint
- Virtual repos with index merging (Helm YAML + PyPI HTML)

Frontend (React + Vite, separate Dockerfile):
- Dashboard with stats, health indicators, top remotes
- Remotes list with type filter, remote detail with config/patterns
- Object browser with pagination and evict
- Test Remote page: probe any remote path, see headers/size/timing
- Virtuals page with expandable member lists

TUI (Bubble Tea):
- Dashboard, remotes list/detail, object browser, virtuals
- Vim-style navigation, artifactapi tui --endpoint <url>

Infrastructure:
- S3 client supports MinIO, Ceph RGW, AWS S3 (minio-go)
- PostgreSQL schema with migrations
- Docker Compose: API + UI + Postgres 17 + Redis 7 + MinIO
- Makefile with Go version check, build/test/lint/fmt/e2e targets
- Distroless Docker image (~15MB)

Testing:
- Unit tests for models, classifier, providers, mergers
- E2E tests with testcontainers-go (real Postgres/Redis/MinIO)

Terraform config:
- All 40 production remotes + helm virtual as HCL
- Provider repo: terraform-provider-artifactapi v0.0.1 (separate)

---------

Co-authored-by: Ben Vincent <ben@unkin.net>
Reviewed-on: #47
This commit was merged in pull request #47.
This commit is contained in:
2026-06-07 19:30:35 +10:00
parent f25bf6cb29
commit b46c116f6b
160 changed files with 11448 additions and 7907 deletions
+106
View File
@@ -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"
}
+49
View File
@@ -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()
}
}
}
+43
View File
@@ -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)
}
+57
View File
@@ -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)
}
+109
View File
@@ -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,
},
})
}
+96
View File
@@ -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)
}
+42
View File
@@ -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)
}
+86
View File
@@ -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)
}