diff --git a/README.md b/README.md index 5b4a4e9..45ccf41 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` | | `nuget` | `index.json` (service index), registration and flat container indexes | `.nupkg`, `.nuspec` | | `github_rpm` | `repodata/*` (synthesized) | `.rpm` (redirected) | diff --git a/e2e-docker/README.md b/e2e-docker/README.md index ec2ee0c..e5c8070 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, nuget) proxied through the + helm, pypi, npm, rpm, alpine, puppet, terraform, goproxy, cargo, gems, nuget) 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 420d7f1..19bdb5a 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"}, {"nuget", "nuget/e2epkg/1.0.0/e2epkg.1.0.0.nupkg", "nuget/e2epkg/1.0.0/e2epkg.1.0.0.nupkg"}, {"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/provider/gems/gems.go b/internal/provider/gems/gems.go new file mode 100644 index 0000000..39d324e --- /dev/null +++ b/internal/provider/gems/gems.go @@ -0,0 +1,62 @@ +// 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" + } + if strings.HasPrefix(path, "api/v1/") && strings.HasSuffix(path, ".json") { + 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(_ []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..9707bb3 --- /dev/null +++ b/internal/provider/gems/gems_test.go @@ -0,0 +1,61 @@ +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", + "api/v1/gems/rack.json": "application/json", + "api/v1/dependencies.json": "application/json", + "api/v1/dependencies": "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 eb6adac..1080065 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 5e310cb..803c8b2 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" PackageNuGet PackageType = "nuget" PackageGitHubRPM PackageType = "github_rpm" PackageGitHubDeb PackageType = "github_deb" @@ -36,6 +37,7 @@ var validPackageTypes = map[PackageType]bool{ PackageTerraform: true, PackageGoProxy: true, PackageCargo: true, + PackageGems: true, PackageNuGet: true, PackageGitHubRPM: true, PackageGitHubDeb: true, diff --git a/pkg/models/package_type_test.go b/pkg/models/package_type_test.go index dd519cf..02aa351 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.PackageNuGet, models.PackageGitHubRPM, models.PackageGitHubDeb, diff --git a/ui/src/components/UsageInstructions.tsx b/ui/src/components/UsageInstructions.tsx index 92c1e57..9014636 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 'nuget': return [ { diff --git a/ui/src/pages/Locals.tsx b/ui/src/pages/Locals.tsx index ba895ee..362b996 100644 --- a/ui/src/pages/Locals.tsx +++ b/ui/src/pages/Locals.tsx @@ -18,6 +18,7 @@ const typeColors: Record