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
+137
View File
@@ -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 ""
}
+109
View File
@@ -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...)
}
+159
View File
@@ -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)
}
}
+38
View File
@@ -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)
}