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
+48
View File
@@ -0,0 +1,48 @@
package alpine
import (
"context"
"net/http"
"strings"
"git.unkin.net/unkin/artifactapi/internal/auth"
"git.unkin.net/unkin/artifactapi/internal/provider"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
func init() {
provider.Register(&Provider{})
}
type Provider struct{}
func (p *Provider) Type() models.PackageType { return models.PackageAlpine }
func (p *Provider) Classify(path string) provider.Mutability {
if strings.HasSuffix(path, "APKINDEX.tar.gz") {
return provider.Mutable
}
return provider.Immutable
}
func (p *Provider) ContentType(path string) string {
if strings.HasSuffix(path, ".apk") {
return "application/vnd.android.package-archive"
}
if strings.HasSuffix(path, ".tar.gz") {
return "application/gzip"
}
return "application/octet-stream"
}
func (p *Provider) UpstreamURL(remote models.Remote, path string) string {
return strings.TrimRight(remote.BaseURL, "/") + "/" + strings.TrimLeft(path, "/")
}
func (p *Provider) RewriteResponse(_ []byte, _ models.Remote, _ string) ([]byte, error) {
return nil, nil
}
func (p *Provider) AuthHeaders(_ context.Context, remote models.Remote) (http.Header, error) {
return auth.BasicHeaders(remote), nil
}
+62
View File
@@ -0,0 +1,62 @@
package docker
import (
"context"
"encoding/base64"
"net/http"
"regexp"
"strings"
"git.unkin.net/unkin/artifactapi/internal/provider"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
func init() {
provider.Register(&Provider{})
}
var (
tagManifestRe = regexp.MustCompile(`/manifests/[^/]+$`)
digestManifestRe = regexp.MustCompile(`/manifests/sha256:[0-9a-fA-F]+$`)
tagsListRe = regexp.MustCompile(`/tags/list$`)
)
type Provider struct{}
func (p *Provider) Type() models.PackageType { return models.PackageDocker }
func (p *Provider) Classify(path string) provider.Mutability {
if tagsListRe.MatchString(path) {
return provider.Mutable
}
if tagManifestRe.MatchString(path) && !digestManifestRe.MatchString(path) {
return provider.Mutable
}
return provider.Immutable
}
func (p *Provider) ContentType(path string) string {
if strings.Contains(path, "/blobs/") {
return "application/octet-stream"
}
if strings.Contains(path, "/manifests/") {
return "application/vnd.docker.distribution.manifest.v2+json"
}
return "application/json"
}
func (p *Provider) UpstreamURL(remote models.Remote, path string) string {
return strings.TrimRight(remote.BaseURL, "/") + "/v2/" + strings.TrimLeft(path, "/")
}
func (p *Provider) RewriteResponse(_ []byte, _ models.Remote, _ string) ([]byte, error) {
return nil, nil
}
func (p *Provider) AuthHeaders(_ context.Context, remote models.Remote) (http.Header, error) {
h := http.Header{}
if remote.Username != "" && remote.Password != "" {
h.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(remote.Username+":"+remote.Password)))
}
return h, nil
}
+54
View File
@@ -0,0 +1,54 @@
package docker_test
import (
"testing"
"git.unkin.net/unkin/artifactapi/internal/provider"
"git.unkin.net/unkin/artifactapi/internal/provider/docker"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
func TestProvider_Type(t *testing.T) {
p := &docker.Provider{}
if p.Type() != models.PackageDocker {
t.Errorf("expected docker, got %q", p.Type())
}
}
func TestProvider_Classify(t *testing.T) {
p := &docker.Provider{}
tests := []struct {
path string
want provider.Mutability
}{
{"library/nginx/manifests/latest", provider.Mutable},
{"library/nginx/manifests/v1.25", provider.Mutable},
{"library/nginx/manifests/sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", provider.Immutable},
{"library/nginx/tags/list", provider.Mutable},
{"library/nginx/blobs/sha256:abc123", provider.Immutable},
}
for _, tt := range tests {
if got := p.Classify(tt.path); got != tt.want {
t.Errorf("Classify(%q) = %v, want %v", tt.path, got, tt.want)
}
}
}
func TestProvider_UpstreamURL(t *testing.T) {
p := &docker.Provider{}
got := p.UpstreamURL(models.Remote{BaseURL: "https://registry-1.docker.io"}, "library/nginx/manifests/latest")
want := "https://registry-1.docker.io/v2/library/nginx/manifests/latest"
if got != want {
t.Errorf("got %q, want %q", got, want)
}
}
func TestProvider_ContentType(t *testing.T) {
p := &docker.Provider{}
if p.ContentType("x/blobs/sha256:abc") != "application/octet-stream" {
t.Error("blobs should be octet-stream")
}
if p.ContentType("x/manifests/latest") != "application/vnd.docker.distribution.manifest.v2+json" {
t.Error("manifests should be manifest type")
}
}
+68
View File
@@ -0,0 +1,68 @@
package generic
import (
"context"
"encoding/base64"
"net/http"
"path"
"strings"
"git.unkin.net/unkin/artifactapi/internal/provider"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
func init() {
provider.Register(&Provider{})
}
type Provider struct{}
func (p *Provider) Type() models.PackageType { return models.PackageGeneric }
func (p *Provider) Classify(_ string) provider.Mutability {
return provider.Immutable
}
var contentTypeMap = map[string]string{
".tar.gz": "application/gzip",
".tgz": "application/gzip",
".gz": "application/gzip",
".zip": "application/zip",
".whl": "application/zip",
".exe": "application/x-msdownload",
".rpm": "application/x-rpm",
".xml": "application/xml",
".yaml": "text/yaml",
".yml": "text/yaml",
".json": "application/json",
".sig": "application/octet-stream",
}
func (p *Provider) ContentType(filePath string) string {
lower := strings.ToLower(filePath)
if strings.HasSuffix(lower, ".tar.gz") {
return "application/gzip"
}
ext := path.Ext(lower)
if ct, ok := contentTypeMap[ext]; ok {
return ct
}
return "application/octet-stream"
}
func (p *Provider) UpstreamURL(remote models.Remote, reqPath string) string {
base := strings.TrimRight(remote.BaseURL, "/")
return base + "/" + strings.TrimLeft(reqPath, "/")
}
func (p *Provider) RewriteResponse(_ []byte, _ models.Remote, _ string) ([]byte, error) {
return nil, nil
}
func (p *Provider) AuthHeaders(_ context.Context, remote models.Remote) (http.Header, error) {
h := http.Header{}
if remote.Username != "" {
h.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(remote.Username+":"+remote.Password)))
}
return h, nil
}
+69
View File
@@ -0,0 +1,69 @@
package generic_test
import (
"context"
"testing"
"git.unkin.net/unkin/artifactapi/internal/provider"
"git.unkin.net/unkin/artifactapi/internal/provider/generic"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
func TestProvider_Type(t *testing.T) {
p := &generic.Provider{}
if p.Type() != models.PackageGeneric {
t.Errorf("expected generic, got %q", p.Type())
}
}
func TestProvider_Classify_AllImmutable(t *testing.T) {
p := &generic.Provider{}
paths := []string{"file.tar.gz", "path/to/binary", "index.html", "data.json"}
for _, path := range paths {
if p.Classify(path) != provider.Immutable {
t.Errorf("generic should classify %q as immutable", path)
}
}
}
func TestProvider_ContentType(t *testing.T) {
p := &generic.Provider{}
tests := []struct{ path, want string }{
{"file.tar.gz", "application/gzip"},
{"file.tgz", "application/gzip"},
{"file.zip", "application/zip"},
{"file.rpm", "application/x-rpm"},
{"file.json", "application/json"},
{"file.unknown", "application/octet-stream"},
}
for _, tt := range tests {
if got := p.ContentType(tt.path); got != tt.want {
t.Errorf("ContentType(%q) = %q, want %q", tt.path, got, tt.want)
}
}
}
func TestProvider_UpstreamURL(t *testing.T) {
p := &generic.Provider{}
got := p.UpstreamURL(models.Remote{BaseURL: "https://example.com/repo"}, "path/to/file.tar.gz")
want := "https://example.com/repo/path/to/file.tar.gz"
if got != want {
t.Errorf("got %q, want %q", got, want)
}
}
func TestProvider_AuthHeaders_BasicAuth(t *testing.T) {
p := &generic.Provider{}
h, _ := p.AuthHeaders(context.Background(), models.Remote{Username: "user", Password: "pass"})
if h.Get("Authorization") != "Basic dXNlcjpwYXNz" {
t.Errorf("unexpected auth header: %q", h.Get("Authorization"))
}
}
func TestProvider_AuthHeaders_NoAuth(t *testing.T) {
p := &generic.Provider{}
h, _ := p.AuthHeaders(context.Background(), models.Remote{})
if h.Get("Authorization") != "" {
t.Error("expected no auth header")
}
}
+54
View File
@@ -0,0 +1,54 @@
package goproxy
import (
"context"
"net/http"
"strings"
"git.unkin.net/unkin/artifactapi/internal/auth"
"git.unkin.net/unkin/artifactapi/internal/provider"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
func init() {
provider.Register(&Provider{})
}
type Provider struct{}
func (p *Provider) Type() models.PackageType { return models.PackageGoProxy }
func (p *Provider) Classify(path string) provider.Mutability {
if strings.HasSuffix(path, "/@v/list") || strings.HasSuffix(path, "/@latest") {
return provider.Mutable
}
return provider.Immutable
}
func (p *Provider) ContentType(path string) string {
if strings.HasSuffix(path, ".zip") {
return "application/zip"
}
if strings.HasSuffix(path, ".mod") {
return "text/plain"
}
if strings.HasSuffix(path, ".info") {
return "application/json"
}
if strings.HasSuffix(path, "/list") {
return "text/plain"
}
return "application/octet-stream"
}
func (p *Provider) UpstreamURL(remote models.Remote, path string) string {
return strings.TrimRight(remote.BaseURL, "/") + "/" + strings.TrimLeft(path, "/")
}
func (p *Provider) RewriteResponse(_ []byte, _ models.Remote, _ string) ([]byte, error) {
return nil, nil
}
func (p *Provider) AuthHeaders(_ context.Context, remote models.Remote) (http.Header, error) {
return auth.BasicHeaders(remote), nil
}
+50
View File
@@ -0,0 +1,50 @@
package goproxy_test
import (
"testing"
"git.unkin.net/unkin/artifactapi/internal/provider"
"git.unkin.net/unkin/artifactapi/internal/provider/goproxy"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
func TestProvider_Type(t *testing.T) {
p := &goproxy.Provider{}
if p.Type() != models.PackageGoProxy {
t.Errorf("expected goproxy, got %q", p.Type())
}
}
func TestProvider_Classify(t *testing.T) {
p := &goproxy.Provider{}
tests := []struct {
path string
want provider.Mutability
}{
{"golang.org/x/net/@v/list", provider.Mutable},
{"golang.org/x/net/@latest", provider.Mutable},
{"golang.org/x/net/@v/v0.1.0.info", provider.Immutable},
{"golang.org/x/net/@v/v0.1.0.mod", provider.Immutable},
{"golang.org/x/net/@v/v0.1.0.zip", provider.Immutable},
}
for _, tt := range tests {
if got := p.Classify(tt.path); got != tt.want {
t.Errorf("Classify(%q) = %v, want %v", tt.path, got, tt.want)
}
}
}
func TestProvider_ContentType(t *testing.T) {
p := &goproxy.Provider{}
tests := []struct{ path, want string }{
{"m/@v/v1.0.0.zip", "application/zip"},
{"m/@v/v1.0.0.mod", "text/plain"},
{"m/@v/v1.0.0.info", "application/json"},
{"m/@v/list", "text/plain"},
}
for _, tt := range tests {
if got := p.ContentType(tt.path); got != tt.want {
t.Errorf("ContentType(%q) = %q, want %q", tt.path, got, tt.want)
}
}
}
+58
View File
@@ -0,0 +1,58 @@
package helm
import (
"context"
"net/http"
"strings"
"git.unkin.net/unkin/artifactapi/internal/auth"
"git.unkin.net/unkin/artifactapi/internal/provider"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
func init() {
provider.Register(&Provider{})
}
type Provider struct{}
func (p *Provider) Type() models.PackageType { return models.PackageHelm }
func (p *Provider) Classify(path string) provider.Mutability {
if strings.HasSuffix(path, "index.yaml") || strings.HasSuffix(path, "index.yml") {
return provider.Mutable
}
return provider.Immutable
}
func (p *Provider) ContentType(path string) string {
if strings.HasSuffix(path, ".tgz") || strings.HasSuffix(path, ".tar.gz") {
return "application/gzip"
}
if strings.HasSuffix(path, ".yaml") || strings.HasSuffix(path, ".yml") {
return "text/yaml"
}
return "application/octet-stream"
}
func (p *Provider) UpstreamURL(remote models.Remote, path string) string {
return strings.TrimRight(remote.BaseURL, "/") + "/" + strings.TrimLeft(path, "/")
}
func (p *Provider) RewriteResponse(body []byte, remote models.Remote, proxyBaseURL string) ([]byte, error) {
if proxyBaseURL == "" {
return nil, nil
}
content := string(body)
baseURL := strings.TrimRight(remote.BaseURL, "/")
proxyURL := strings.TrimRight(proxyBaseURL, "/") + "/api/v1/remote/" + remote.Name
rewritten := strings.ReplaceAll(content, baseURL, proxyURL)
if rewritten == content {
return nil, nil
}
return []byte(rewritten), nil
}
func (p *Provider) AuthHeaders(_ context.Context, remote models.Remote) (http.Header, error) {
return auth.BasicHeaders(remote), nil
}
+51
View File
@@ -0,0 +1,51 @@
package helm_test
import (
"strings"
"testing"
"git.unkin.net/unkin/artifactapi/internal/provider"
"git.unkin.net/unkin/artifactapi/internal/provider/helm"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
func TestProvider_Type(t *testing.T) {
p := &helm.Provider{}
if p.Type() != models.PackageHelm {
t.Errorf("expected helm, got %q", p.Type())
}
}
func TestProvider_Classify(t *testing.T) {
p := &helm.Provider{}
tests := []struct {
path string
want provider.Mutability
}{
{"index.yaml", provider.Mutable},
{"index.yml", provider.Mutable},
{"chart-1.0.tgz", provider.Immutable},
{"charts/nginx-1.0.tgz", provider.Immutable},
}
for _, tt := range tests {
if got := p.Classify(tt.path); got != tt.want {
t.Errorf("Classify(%q) = %v, want %v", tt.path, got, tt.want)
}
}
}
func TestProvider_RewriteResponse(t *testing.T) {
p := &helm.Provider{}
body := []byte("urls:\n- https://charts.example.com/chart-1.0.tgz")
remote := models.Remote{Name: "helm-test", BaseURL: "https://charts.example.com"}
rewritten, err := p.RewriteResponse(body, remote, "https://proxy.example.com")
if err != nil {
t.Fatal(err)
}
if rewritten == nil {
t.Fatal("expected rewrite")
}
if !strings.Contains(string(rewritten), "proxy.example.com/api/v1/remote/helm-test") {
t.Errorf("expected proxy URL in body: %s", rewritten)
}
}
+56
View File
@@ -0,0 +1,56 @@
package npm
import (
"context"
"encoding/json"
"net/http"
"strings"
"git.unkin.net/unkin/artifactapi/internal/auth"
"git.unkin.net/unkin/artifactapi/internal/provider"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
func init() {
provider.Register(&Provider{})
}
type Provider struct{}
func (p *Provider) Type() models.PackageType { return models.PackageNPM }
func (p *Provider) Classify(path string) provider.Mutability {
if strings.HasSuffix(path, ".tgz") {
return provider.Immutable
}
return provider.Mutable
}
func (p *Provider) ContentType(path string) string {
if strings.HasSuffix(path, ".tgz") {
return "application/gzip"
}
return "application/json"
}
func (p *Provider) UpstreamURL(remote models.Remote, path string) string {
return strings.TrimRight(remote.BaseURL, "/") + "/" + strings.TrimLeft(path, "/")
}
func (p *Provider) RewriteResponse(body []byte, remote models.Remote, proxyBaseURL string) ([]byte, error) {
if proxyBaseURL == "" || !json.Valid(body) {
return nil, nil
}
content := string(body)
baseURL := strings.TrimRight(remote.BaseURL, "/")
proxyURL := strings.TrimRight(proxyBaseURL, "/") + "/api/v1/remote/" + remote.Name
rewritten := strings.ReplaceAll(content, baseURL, proxyURL)
if rewritten == content {
return nil, nil
}
return []byte(rewritten), nil
}
func (p *Provider) AuthHeaders(_ context.Context, remote models.Remote) (http.Header, error) {
return auth.BasicHeaders(remote), nil
}
+52
View File
@@ -0,0 +1,52 @@
package provider
import (
"context"
"fmt"
"net/http"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
type Mutability int
const (
Immutable Mutability = iota
Mutable
)
type Provider interface {
Type() models.PackageType
Classify(path string) Mutability
ContentType(path string) string
UpstreamURL(remote models.Remote, path string) string
RewriteResponse(body []byte, remote models.Remote, proxyBaseURL string) ([]byte, error)
AuthHeaders(ctx context.Context, remote models.Remote) (http.Header, error)
}
type IndexMerger interface {
MergeIndexes(members []MemberIndex, proxyBaseURL string) ([]byte, error)
}
type MemberIndex struct {
RemoteName string
Body []byte
}
var registry = map[models.PackageType]Provider{}
func Register(p Provider) {
registry[p.Type()] = p
}
func Get(t models.PackageType) (Provider, error) {
p, ok := registry[t]
if !ok {
return nil, fmt.Errorf("no provider registered for package type %q", t)
}
return p, nil
}
func All() map[models.PackageType]Provider {
return registry
}
+56
View File
@@ -0,0 +1,56 @@
package puppet
import (
"context"
"net/http"
"strings"
"git.unkin.net/unkin/artifactapi/internal/auth"
"git.unkin.net/unkin/artifactapi/internal/provider"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
func init() {
provider.Register(&Provider{})
}
type Provider struct{}
func (p *Provider) Type() models.PackageType { return models.PackagePuppet }
func (p *Provider) Classify(path string) provider.Mutability {
if strings.HasPrefix(path, "v3/modules/") || strings.HasPrefix(path, "v3/releases") {
return provider.Mutable
}
return provider.Immutable
}
func (p *Provider) ContentType(path string) string {
if strings.HasSuffix(path, ".tar.gz") {
return "application/gzip"
}
if strings.HasPrefix(path, "v3/") {
return "application/json"
}
return "application/octet-stream"
}
func (p *Provider) UpstreamURL(remote models.Remote, path string) string {
return strings.TrimRight(remote.BaseURL, "/") + "/" + strings.TrimLeft(path, "/")
}
func (p *Provider) RewriteResponse(body []byte, remote models.Remote, proxyBaseURL string) ([]byte, error) {
if proxyBaseURL == "" {
return nil, nil
}
content := string(body)
proxyURL := strings.TrimRight(proxyBaseURL, "/") + "/api/v1/remote/" + remote.Name
content = strings.ReplaceAll(content, `"/v3/files/`, `"`+proxyURL+`/v3/files/`)
baseURL := strings.TrimRight(remote.BaseURL, "/")
content = strings.ReplaceAll(content, baseURL, proxyURL)
return []byte(content), nil
}
func (p *Provider) AuthHeaders(_ context.Context, remote models.Remote) (http.Header, error) {
return auth.BasicHeaders(remote), nil
}
+62
View File
@@ -0,0 +1,62 @@
package pypi
import (
"context"
"net/http"
"strings"
"git.unkin.net/unkin/artifactapi/internal/auth"
"git.unkin.net/unkin/artifactapi/internal/provider"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
func init() {
provider.Register(&Provider{})
}
type Provider struct{}
func (p *Provider) Type() models.PackageType { return models.PackagePyPI }
func (p *Provider) Classify(path string) provider.Mutability {
if strings.Contains(path, "simple/") {
return provider.Mutable
}
return provider.Immutable
}
func (p *Provider) ContentType(path string) string {
lower := strings.ToLower(path)
if strings.HasSuffix(lower, ".whl") || strings.HasSuffix(lower, ".zip") {
return "application/zip"
}
if strings.HasSuffix(lower, ".tar.gz") {
return "application/gzip"
}
if strings.Contains(path, "simple/") {
return "text/html"
}
return "application/octet-stream"
}
func (p *Provider) UpstreamURL(remote models.Remote, path string) string {
if strings.HasPrefix(path, "simple/") {
return "https://pypi.org/" + path
}
return strings.TrimRight(remote.BaseURL, "/") + "/" + strings.TrimLeft(path, "/")
}
func (p *Provider) RewriteResponse(body []byte, remote models.Remote, proxyBaseURL string) ([]byte, error) {
if proxyBaseURL == "" {
return nil, nil
}
content := string(body)
proxyURL := strings.TrimRight(proxyBaseURL, "/") + "/api/v1/remote/" + remote.Name + "/"
content = strings.ReplaceAll(content, "https://files.pythonhosted.org/", proxyURL)
content = strings.ReplaceAll(content, "../../", proxyURL)
return []byte(content), nil
}
func (p *Provider) AuthHeaders(_ context.Context, remote models.Remote) (http.Header, error) {
return auth.BasicHeaders(remote), nil
}
+57
View File
@@ -0,0 +1,57 @@
package rpm
import (
"context"
"net/http"
"regexp"
"strings"
"git.unkin.net/unkin/artifactapi/internal/auth"
"git.unkin.net/unkin/artifactapi/internal/provider"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
func init() {
provider.Register(&Provider{})
}
var mutableRe = []*regexp.Regexp{
regexp.MustCompile(`repomd\.xml$`),
regexp.MustCompile(`repodata/`),
regexp.MustCompile(`Packages\.gz$`),
}
type Provider struct{}
func (p *Provider) Type() models.PackageType { return models.PackageRPM }
func (p *Provider) Classify(path string) provider.Mutability {
for _, re := range mutableRe {
if re.MatchString(path) {
return provider.Mutable
}
}
return provider.Immutable
}
func (p *Provider) ContentType(path string) string {
if strings.HasSuffix(path, ".rpm") {
return "application/x-rpm"
}
if strings.HasSuffix(path, ".xml") || strings.HasSuffix(path, ".xml.gz") || strings.HasSuffix(path, ".xml.xz") {
return "application/xml"
}
return "application/octet-stream"
}
func (p *Provider) UpstreamURL(remote models.Remote, path string) string {
return strings.TrimRight(remote.BaseURL, "/") + "/" + strings.TrimLeft(path, "/")
}
func (p *Provider) RewriteResponse(_ []byte, _ models.Remote, _ string) ([]byte, error) {
return nil, nil
}
func (p *Provider) AuthHeaders(_ context.Context, remote models.Remote) (http.Header, error) {
return auth.BasicHeaders(remote), nil
}
+35
View File
@@ -0,0 +1,35 @@
package rpm_test
import (
"testing"
"git.unkin.net/unkin/artifactapi/internal/provider"
"git.unkin.net/unkin/artifactapi/internal/provider/rpm"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
func TestProvider_Type(t *testing.T) {
p := &rpm.Provider{}
if p.Type() != models.PackageRPM {
t.Errorf("expected rpm, got %q", p.Type())
}
}
func TestProvider_Classify(t *testing.T) {
p := &rpm.Provider{}
tests := []struct {
path string
want provider.Mutability
}{
{"repomd.xml", provider.Mutable},
{"repodata/primary.xml.gz", provider.Mutable},
{"Packages.gz", provider.Mutable},
{"package-1.0.rpm", provider.Immutable},
{"RPM-GPG-KEY-almalinux", provider.Immutable},
}
for _, tt := range tests {
if got := p.Classify(tt.path); got != tt.want {
t.Errorf("Classify(%q) = %v, want %v", tt.path, got, tt.want)
}
}
}
+88
View File
@@ -0,0 +1,88 @@
package terraform
import (
"context"
"encoding/json"
"net/http"
"net/url"
"regexp"
"strings"
"git.unkin.net/unkin/artifactapi/internal/auth"
"git.unkin.net/unkin/artifactapi/internal/provider"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
func init() {
provider.Register(&Provider{})
}
var versionsRe = regexp.MustCompile(`[^/]+/[^/]+/versions$`)
type Provider struct{}
func (p *Provider) Type() models.PackageType { return models.PackageTerraform }
func (p *Provider) Classify(path string) provider.Mutability {
if versionsRe.MatchString(path) {
return provider.Mutable
}
return provider.Immutable
}
func (p *Provider) ContentType(path string) string {
lower := strings.ToLower(path)
if strings.HasSuffix(lower, ".zip") {
return "application/zip"
}
if strings.HasSuffix(lower, ".sig") {
return "application/octet-stream"
}
return "application/json"
}
func (p *Provider) UpstreamURL(remote models.Remote, path string) string {
return strings.TrimRight(remote.BaseURL, "/") + "/v1/providers/" + strings.TrimLeft(path, "/")
}
func (p *Provider) RewriteResponse(body []byte, remote models.Remote, proxyBaseURL string) ([]byte, error) {
if remote.ReleasesRemote == "" {
return nil, nil
}
if !json.Valid(body) {
return nil, nil
}
var data map[string]any
if err := json.Unmarshal(body, &data); err != nil {
return nil, nil
}
changed := false
for _, field := range []string{"download_url", "shasums_url", "shasums_signature_url"} {
if val, ok := data[field].(string); ok && val != "" {
rewritten := rewriteDownloadURL(val, remote.ReleasesRemote, proxyBaseURL)
if rewritten != val {
data[field] = rewritten
changed = true
}
}
}
if !changed {
return nil, nil
}
return json.Marshal(data)
}
func rewriteDownloadURL(originalURL, releasesRemote, proxyBaseURL string) string {
parsed, err := url.Parse(originalURL)
if err != nil || proxyBaseURL == "" {
return originalURL
}
return strings.TrimRight(proxyBaseURL, "/") + "/api/v1/remote/" + releasesRemote + parsed.Path
}
func (p *Provider) AuthHeaders(_ context.Context, remote models.Remote) (http.Header, error) {
return auth.BasicHeaders(remote), nil
}
@@ -0,0 +1,55 @@
package terraform_test
import (
"encoding/json"
"strings"
"testing"
"git.unkin.net/unkin/artifactapi/internal/provider"
"git.unkin.net/unkin/artifactapi/internal/provider/terraform"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
func TestProvider_Type(t *testing.T) {
p := &terraform.Provider{}
if p.Type() != models.PackageTerraform {
t.Errorf("expected terraform, got %q", p.Type())
}
}
func TestProvider_Classify(t *testing.T) {
p := &terraform.Provider{}
tests := []struct {
path string
want provider.Mutability
}{
{"hashicorp/vault/versions", provider.Mutable},
{"hashicorp/vault/0.28.0/download/linux/amd64", provider.Immutable},
}
for _, tt := range tests {
if got := p.Classify(tt.path); got != tt.want {
t.Errorf("Classify(%q) = %v, want %v", tt.path, got, tt.want)
}
}
}
func TestProvider_RewriteResponse_DownloadInfo(t *testing.T) {
p := &terraform.Provider{}
remote := models.Remote{Name: "tf", ReleasesRemote: "hashicorp-releases"}
body, _ := json.Marshal(map[string]any{
"download_url": "https://releases.hashicorp.com/terraform-provider-vault/0.28.0/file.zip",
"shasums_url": "https://releases.hashicorp.com/terraform-provider-vault/0.28.0/SHA256SUMS",
})
rewritten, err := p.RewriteResponse(body, remote, "https://proxy")
if err != nil {
t.Fatal(err)
}
if rewritten == nil {
t.Fatal("expected rewrite")
}
var result map[string]any
json.Unmarshal(rewritten, &result)
if !strings.Contains(result["download_url"].(string), "proxy/api/v1/remote/hashicorp-releases") {
t.Errorf("download_url not rewritten: %s", result["download_url"])
}
}