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
+202
View File
@@ -0,0 +1,202 @@
import { useEffect, useState } from 'react';
import { api } from '../api/client';
import type { Remote, ProbeResult } from '../api/types';
import { Badge } from '../components/Badge';
import { formatBytes } from '../components/format';
import './Probe.css';
const PRESETS: Record<string, { remote: string; path: string }[]> = {
'gitea-dl': [
{ remote: 'gitea-dl', path: 'gitea/1.23.7/gitea-1.23.7-linux-amd64' },
{ remote: 'gitea-dl', path: 'act_runner/0.2.11/act_runner-0.2.11-linux-amd64' },
],
'github': [
{ remote: 'github', path: 'ducaale/xh/releases/download/v0.24.0/xh-v0.24.0-x86_64-unknown-linux-musl.tar.gz' },
{ remote: 'github', path: 'mikefarah/yq/releases/download/v4.45.4/yq_linux_amd64' },
{ remote: 'github', path: 'neovim/neovim-releases/releases/download/v0.11.2/nvim-linux-x86_64.tar.gz' },
],
'hashicorp-releases': [
{ remote: 'hashicorp-releases', path: 'terraform/1.12.2/terraform_1.12.2_linux_amd64.zip' },
],
'goproxy': [
{ remote: 'goproxy', path: 'golang.org/x/net/@v/list' },
{ remote: 'goproxy', path: 'golang.org/x/net/@v/v0.55.0.info' },
],
};
export function Probe() {
const [remotes, setRemotes] = useState<Remote[]>([]);
const [remote, setRemote] = useState('');
const [path, setPath] = useState('');
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<ProbeResult | null>(null);
const [history, setHistory] = useState<(ProbeResult & { remote: string; path: string })[]>([]);
useEffect(() => {
api.listRemotes().then(r => {
setRemotes(r || []);
if (r?.length && !remote) setRemote(r[0].name);
});
}, []);
const runProbe = async () => {
if (!remote || !path) return;
setLoading(true);
setResult(null);
try {
const r = await api.probe(remote, path);
setResult(r);
setHistory(prev => [{ ...r, remote, path }, ...prev].slice(0, 20));
} catch (e: unknown) {
setResult({
status: 0,
source: '',
content_type: '',
size_bytes: 0,
headers: {},
duration_ms: 0,
error: e instanceof Error ? e.message : String(e),
});
} finally {
setLoading(false);
}
};
const applyPreset = (r: string, p: string) => {
setRemote(r);
setPath(p);
};
const remotePresets = PRESETS[remote] || [];
return (
<div>
<h1 className="page-title">Test Remote</h1>
<p className="probe-description">
Probe a remote to test connectivity and caching. The file is fetched and cached but not sent to your browser.
</p>
<div className="probe-form">
<div className="probe-row">
<label>Remote</label>
<select
className="type-select probe-select"
value={remote}
onChange={e => setRemote(e.target.value)}
>
{remotes.map(r => (
<option key={r.name} value={r.name}>
{r.name} ({r.package_type})
</option>
))}
</select>
</div>
<div className="probe-row">
<label>Path</label>
<input
className="search-input probe-input"
placeholder="e.g. gitea/1.23.7/gitea-1.23.7-linux-amd64"
value={path}
onChange={e => setPath(e.target.value)}
onKeyDown={e => e.key === 'Enter' && runProbe()}
/>
</div>
<button className="btn btn-primary" onClick={runProbe} disabled={loading || !path}>
{loading ? 'Probing...' : 'Probe'}
</button>
</div>
{remotePresets.length > 0 && (
<div className="presets">
<span className="presets-label">Quick tests:</span>
{remotePresets.map((p, i) => (
<button
key={i}
className="preset-btn"
onClick={() => applyPreset(p.remote, p.path)}
>
{p.path.split('/').pop()}
</button>
))}
</div>
)}
{result && (
<div className={`probe-result ${result.status === 200 ? 'probe-ok' : 'probe-err'}`}>
<div className="probe-result-header">
<Badge variant={result.status === 200 ? 'green' : 'red'}>
{result.status}
</Badge>
<Badge variant={result.source === 'cache' ? 'blue' : 'yellow'}>
{result.source || 'error'}
</Badge>
<span className="probe-duration">{result.duration_ms}ms</span>
</div>
{result.error ? (
<div className="probe-error">{result.error}</div>
) : (
<dl className="probe-dl">
<dt>Content-Type</dt>
<dd className="mono">{result.content_type}</dd>
<dt>Size</dt>
<dd className="mono">{formatBytes(result.size_bytes)} ({result.size_bytes.toLocaleString()} bytes)</dd>
<dt>Source</dt>
<dd>{result.source === 'cache' ? 'Served from cache (S3)' : 'Fetched from upstream'}</dd>
<dt>Duration</dt>
<dd className="mono">{result.duration_ms}ms</dd>
{result.headers && Object.entries(result.headers).map(([k, v]) => (
<div key={k} className="probe-header-row">
<dt className="mono">{k}</dt>
<dd className="mono">{v}</dd>
</div>
))}
</dl>
)}
</div>
)}
{history.length > 0 && (
<div className="probe-history">
<h3 className="section-label">History</h3>
<table className="data-table">
<thead>
<tr>
<th>Remote</th>
<th>Path</th>
<th>Status</th>
<th>Source</th>
<th>Size</th>
<th>Time</th>
</tr>
</thead>
<tbody>
{history.map((h, i) => (
<tr
key={i}
className="clickable"
onClick={() => applyPreset(h.remote, h.path)}
>
<td className="mono">{h.remote}</td>
<td className="mono probe-path-cell">{h.path}</td>
<td>
<Badge variant={h.status === 200 ? 'green' : 'red'}>{h.status}</Badge>
</td>
<td>
<Badge variant={h.source === 'cache' ? 'blue' : 'yellow'}>
{h.source || 'err'}
</Badge>
</td>
<td className="mono">{formatBytes(h.size_bytes)}</td>
<td className="mono">{h.duration_ms}ms</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}