diff --git a/README.md b/README.md index 660a658..52558c6 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ API: `http://localhost:8000` | Frontend: `http://localhost:5173` | `terraform` | `*/versions` | `*/download/*/*` | | `goproxy` | `@v/list`, `@latest` | `.info`, `.mod`, `.zip` | | `cargo` | sparse index files (`config.json` synthesized) | `crates/*/*.crate` | +| `gems` | compact index (`versions`, `info/*`, `names`), `*specs.4.8.gz` | `gems/*.gem`, `quick/Marshal.4.8/*.gemspec.rz` | | `github_rpm` | `repodata/*` (synthesized) | `.rpm` (redirected) | Providers classify paths automatically. Users only configure what to proxy and TTLs. diff --git a/e2e-docker/README.md b/e2e-docker/README.md index 3125d23..6a0faf1 100644 --- a/e2e-docker/README.md +++ b/e2e-docker/README.md @@ -23,7 +23,7 @@ already-running stack. - **Repository lifecycle** — add / change / delete for remote, local and virtual repos. - **Caching** — one immutable artifact per remote package type (generic, docker, - helm, pypi, npm, rpm, alpine, puppet, terraform, goproxy, cargo) proxied through the + helm, pypi, npm, rpm, alpine, puppet, terraform, goproxy, cargo, gems) proxied through the mock upstream: first fetch `X-Artifact-Source: remote`, second `cache`, bytes verified against the origin fixture. - **Local uploads** — generic (upload/download), pypi (wheel + generated `simple/` diff --git a/e2e-docker/caching_test.go b/e2e-docker/caching_test.go index d04ed6f..459fc4e 100644 --- a/e2e-docker/caching_test.go +++ b/e2e-docker/caching_test.go @@ -30,6 +30,7 @@ func TestCachingPerProvider(t *testing.T) { {"puppet", "puppet-releases/author-mod-1.0.0.tar.gz", "puppet-releases/author-mod-1.0.0.tar.gz"}, {"goproxy", "goproxy/example.com/mod/@v/v1.0.0.zip", "goproxy/example.com/mod/@v/v1.0.0.zip"}, {"cargo", "crates/mycrate/mycrate-1.0.0.crate", "crates/mycrate/mycrate-1.0.0.crate"}, + {"gems", "gems/mygem-1.0.0.gem", "gems/mygem-1.0.0.gem"}, {"terraform", "hashicorp/aws/download/pkg.zip", "v1/providers/hashicorp/aws/download/pkg.zip"}, {"docker", "library/testimg/blobs/blobdata", "v2/library/testimg/blobs/blobdata"}, } diff --git a/e2e-docker/fixtures/gems/mygem-1.0.0.gem b/e2e-docker/fixtures/gems/mygem-1.0.0.gem new file mode 100644 index 0000000..666781d --- /dev/null +++ b/e2e-docker/fixtures/gems/mygem-1.0.0.gem @@ -0,0 +1 @@ +mygem-1.0.0 e2e fixture diff --git a/internal/api/v1/proxy.go b/internal/api/v1/proxy.go index e77f43e..942f961 100644 --- a/internal/api/v1/proxy.go +++ b/internal/api/v1/proxy.go @@ -40,6 +40,7 @@ func NewProxyHandler(engine *proxy.Engine, virtualEngine *virtual.Engine, db *da func (h *ProxyHandler) Routes() chi.Router { r := chi.NewRouter() r.Get("/remote/{remoteName}/*", h.handleProxy) + r.Head("/remote/{remoteName}/*", h.handleProxyHead) r.Get("/local/{localName}/*", h.handleLocal) r.Get("/virtual/{virtualName}/*", h.handleVirtual) return r diff --git a/internal/api/v1/routes_test.go b/internal/api/v1/routes_test.go new file mode 100644 index 0000000..d4bba82 --- /dev/null +++ b/internal/api/v1/routes_test.go @@ -0,0 +1,17 @@ +package v1 + +import ( + "net/http" + "testing" + + "github.com/go-chi/chi/v5" +) + +func TestRoutesRemoteHead(t *testing.T) { + r := (&ProxyHandler{}).Routes() + for _, m := range []string{http.MethodGet, http.MethodHead} { + if !r.Match(chi.NewRouteContext(), m, "/remote/rubygems/versions") { + t.Errorf("%s /remote/rubygems/versions not routed", m) + } + } +} diff --git a/internal/provider/gems/gems.go b/internal/provider/gems/gems.go new file mode 100644 index 0000000..64ae038 --- /dev/null +++ b/internal/provider/gems/gems.go @@ -0,0 +1,59 @@ +// Package gems proxies a RubyGems source: mutable compact index (versions, +// info/*, names) and legacy specs indexes, plus immutable .gem and +// quick/Marshal.4.8 gemspec downloads. +package gems + +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.PackageGems } + +func (p *Provider) Classify(path string) provider.Mutability { + if isGem(path) || isQuickSpec(path) { + return provider.Immutable + } + return provider.Mutable +} + +func (p *Provider) ContentType(path string) string { + path = strings.TrimLeft(path, "/") + if path == "versions" || path == "names" || strings.HasPrefix(path, "info/") { + return "text/plain; charset=utf-8" + } + 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 +} + +func isGem(path string) bool { + path = strings.TrimLeft(path, "/") + return strings.HasPrefix(path, "gems/") && strings.HasSuffix(path, ".gem") +} + +func isQuickSpec(path string) bool { + path = strings.TrimLeft(path, "/") + return strings.HasPrefix(path, "quick/Marshal.4.8/") && strings.HasSuffix(path, ".gemspec.rz") +} diff --git a/internal/provider/gems/gems_test.go b/internal/provider/gems/gems_test.go new file mode 100644 index 0000000..297d99f --- /dev/null +++ b/internal/provider/gems/gems_test.go @@ -0,0 +1,58 @@ +package gems + +import ( + "testing" + + "git.unkin.net/unkin/artifactapi/internal/provider" + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +func TestClassify(t *testing.T) { + p := &Provider{} + tests := []struct { + path string + want provider.Mutability + }{ + {"gems/rack-3.1.8.gem", provider.Immutable}, + {"/gems/nokogiri-1.16.7-x86_64-linux.gem", provider.Immutable}, + {"quick/Marshal.4.8/rack-3.1.8.gemspec.rz", provider.Immutable}, + {"versions", provider.Mutable}, + {"names", provider.Mutable}, + {"info/rack", provider.Mutable}, + {"info/gems", provider.Mutable}, + {"specs.4.8.gz", provider.Mutable}, + {"latest_specs.4.8.gz", provider.Mutable}, + {"prerelease_specs.4.8.gz", provider.Mutable}, + {"api/v1/dependencies", provider.Mutable}, + {"quick/Marshal.4.8/", provider.Mutable}, + } + 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 TestContentType(t *testing.T) { + p := &Provider{} + tests := map[string]string{ + "versions": "text/plain; charset=utf-8", + "info/rack": "text/plain; charset=utf-8", + "names": "text/plain; charset=utf-8", + "gems/rack-3.1.8.gem": "application/octet-stream", + "specs.4.8.gz": "application/octet-stream", + } + for path, want := range tests { + if got := p.ContentType(path); got != want { + t.Errorf("ContentType(%q) = %q, want %q", path, got, want) + } + } +} + +func TestUpstreamURL(t *testing.T) { + p := &Provider{} + r := models.Remote{BaseURL: "https://rubygems.org/"} + if got := p.UpstreamURL(r, "/info/rack"); got != "https://rubygems.org/info/rack" { + t.Errorf("UpstreamURL = %q", got) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 88d12ac..49fd080 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -25,6 +25,7 @@ import ( _ "git.unkin.net/unkin/artifactapi/internal/provider/cargo" "git.unkin.net/unkin/artifactapi/internal/provider/deb" _ "git.unkin.net/unkin/artifactapi/internal/provider/docker" + _ "git.unkin.net/unkin/artifactapi/internal/provider/gems" _ "git.unkin.net/unkin/artifactapi/internal/provider/generic" _ "git.unkin.net/unkin/artifactapi/internal/provider/goproxy" _ "git.unkin.net/unkin/artifactapi/internal/provider/helm" diff --git a/pkg/models/package_type.go b/pkg/models/package_type.go index 52c4cc2..e23acf0 100644 --- a/pkg/models/package_type.go +++ b/pkg/models/package_type.go @@ -17,6 +17,7 @@ const ( PackageTerraform PackageType = "terraform" PackageGoProxy PackageType = "goproxy" PackageCargo PackageType = "cargo" + PackageGems PackageType = "gems" PackageGitHubRPM PackageType = "github_rpm" PackageGitHubDeb PackageType = "github_deb" PackageGitHubAlpine PackageType = "github_alpine" @@ -35,6 +36,7 @@ var validPackageTypes = map[PackageType]bool{ PackageTerraform: true, PackageGoProxy: true, PackageCargo: true, + PackageGems: true, PackageGitHubRPM: true, PackageGitHubDeb: true, PackageGitHubAlpine: true, diff --git a/pkg/models/package_type_test.go b/pkg/models/package_type_test.go index 5143487..f389eec 100644 --- a/pkg/models/package_type_test.go +++ b/pkg/models/package_type_test.go @@ -19,6 +19,7 @@ func TestPackageTypeValid(t *testing.T) { models.PackageTerraform, models.PackageGoProxy, models.PackageCargo, + models.PackageGems, models.PackageGitHubRPM, models.PackageGitHubDeb, models.PackageGitHubAlpine, diff --git a/ui/src/components/UsageInstructions.tsx b/ui/src/components/UsageInstructions.tsx index 228a924..23076ec 100644 --- a/ui/src/components/UsageInstructions.tsx +++ b/ui/src/components/UsageInstructions.tsx @@ -243,6 +243,16 @@ index = "sparse+${proxy}/"`, }, ]; + case 'gems': + return [ + { + title: 'Use this remote as the RubyGems source', + language: 'bash', + code: `gem sources --clear-all --add ${proxy}/ +bundle config mirror.https://rubygems.org ${proxy}`, + }, + ]; + case 'puppet': return [ { diff --git a/ui/src/pages/Locals.tsx b/ui/src/pages/Locals.tsx index 83c3fca..0698d63 100644 --- a/ui/src/pages/Locals.tsx +++ b/ui/src/pages/Locals.tsx @@ -18,6 +18,7 @@ const typeColors: Record