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
+129
View File
@@ -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<Remote | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!name) return;
api.getRemote(name)
.then(setRemote)
.catch(e => setError(e.message));
}, [name]);
if (error) return <div className="error-banner">{error}</div>;
if (!remote) return <div className="loading">Loading...</div>;
return (
<div>
<div className="detail-header">
<Link to="/remotes" className="back-link">&larr; Remotes</Link>
<h1 className="page-title">{remote.name}</h1>
<div className="detail-badges">
<Badge variant="blue">{remote.package_type}</Badge>
{remote.managed_by && <Badge variant="green">managed by {remote.managed_by}</Badge>}
</div>
</div>
{remote.description && (
<p className="detail-description">{remote.description}</p>
)}
<div className="detail-grid">
<div className="detail-section">
<h3 className="section-label">Configuration</h3>
<dl className="detail-dl">
<dt>Base URL</dt>
<dd className="mono">{remote.base_url}</dd>
<dt>Immutable TTL</dt>
<dd className="mono">{remote.immutable_ttl === 0 ? 'forever' : `${remote.immutable_ttl}s`}</dd>
<dt>Mutable TTL</dt>
<dd className="mono">{remote.mutable_ttl}s</dd>
<dt>Conditional Revalidation</dt>
<dd>{remote.check_mutable ? 'enabled' : 'disabled'}</dd>
<dt>Stale on Error</dt>
<dd>{remote.stale_on_error ? 'enabled' : 'disabled'}</dd>
{remote.releases_remote && (
<>
<dt>Releases Remote</dt>
<dd>
<Link to={`/remotes/${remote.releases_remote}`} className="mono">
{remote.releases_remote}
</Link>
</dd>
</>
)}
</dl>
</div>
<div className="detail-section">
<h3 className="section-label">Access Control</h3>
<dl className="detail-dl">
<dt>Patterns</dt>
<dd>
{remote.patterns?.length
? <PatternList patterns={remote.patterns} />
: <span className="text-muted">none (proxy all)</span>}
</dd>
<dt>Blocklist</dt>
<dd>
{remote.blocklist?.length
? <PatternList patterns={remote.blocklist} />
: <span className="text-muted">none</span>}
</dd>
</dl>
</div>
<div className="detail-section">
<h3 className="section-label">Classification Overrides</h3>
<dl className="detail-dl">
<dt>Mutable</dt>
<dd>
{remote.mutable_patterns?.length
? <PatternList patterns={remote.mutable_patterns} />
: <span className="text-muted">provider defaults only</span>}
</dd>
<dt>Immutable</dt>
<dd>
{remote.immutable_patterns?.length
? <PatternList patterns={remote.immutable_patterns} />
: <span className="text-muted">provider defaults only</span>}
</dd>
</dl>
</div>
{remote.ban_tags_enabled && (
<div className="detail-section">
<h3 className="section-label">Tag Banning</h3>
<dl className="detail-dl">
<dt>Banned Tags</dt>
<dd><PatternList patterns={remote.ban_tags || []} /></dd>
</dl>
</div>
)}
</div>
<div className="detail-actions">
<Link to={`/remotes/${remote.name}/objects`} className="btn btn-primary">
Browse Objects
</Link>
</div>
</div>
);
}
function PatternList({ patterns }: { patterns: string[] }) {
return (
<ul className="pattern-list">
{patterns.map((p, i) => (
<li key={i} className="mono">{p}</li>
))}
</ul>
);
}