feat: v3 Go rewrite — full artifact proxy with web UI, TUI, and Terraform provider

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)
This commit is contained in:
2026-06-07 15:53:14 +10:00
parent f25bf6cb29
commit deabda9895
111 changed files with 11428 additions and 741 deletions
+305
View File
@@ -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"))
)
+140
View File
@@ -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] + "..."
}
+45
View File
@@ -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,
)
}