Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 95d81c1af0 |
@@ -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.
|
||||
@@ -261,27 +262,6 @@ there's nothing to provision. To bring your own key instead, point
|
||||
`TF_SIGNING_KEY_PASSPHRASE`), which takes precedence over the generated one.
|
||||
`TF_PROVIDER_PROTOCOLS` (default `5.0,6.0`) sets the advertised plugin protocols.
|
||||
|
||||
### Local Go module proxy
|
||||
|
||||
A local `goproxy` repo serves private modules over the GOPROXY protocol
|
||||
(`@v/list`, `@latest`, `@v/<ver>.info|.mod|.zip`). Publish a version by
|
||||
uploading its files under the case-encoded module path (uppercase as `!lower`):
|
||||
|
||||
```sh
|
||||
for ext in zip mod info; do
|
||||
curl -fsSL --upload-file v0.1.0.$ext \
|
||||
https://artifactapi.k8s.syd1.au.unkin.net/api/v2/remotes/go-internal/files/example.unkin.net/hello/@v/v0.1.0.$ext
|
||||
done
|
||||
GOPROXY=https://artifactapi.k8s.syd1.au.unkin.net/api/v1/local/go-internal,https://proxy.golang.org,direct \
|
||||
GONOSUMDB=example.unkin.net go get example.unkin.net/hello@latest
|
||||
```
|
||||
|
||||
Build the zip with `golang.org/x/mod/zip` or reuse the files `go mod download`
|
||||
leaves in `$(go env GOMODCACHE)/cache/download`. Uploads are validated: zips must
|
||||
pass `zip.CheckZip` (every file under `module@version/`), `.mod` must declare the
|
||||
module, and `.info` must match the version. `.info` is optional; it is
|
||||
synthesized from the version when absent. `@v/list` lists versions with a `.mod`.
|
||||
|
||||
### Local docker registry
|
||||
|
||||
A local `docker` repo is a real container registry, not a mirror: it serves the
|
||||
|
||||
@@ -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/`
|
||||
|
||||
@@ -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"},
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
mygem-1.0.0 e2e fixture
|
||||
@@ -1,57 +0,0 @@
|
||||
//go:build dockere2e
|
||||
|
||||
package e2edocker
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/mod/module"
|
||||
modzip "golang.org/x/mod/zip"
|
||||
)
|
||||
|
||||
// TestLocalGoProxy publishes two module versions and reads them back over the
|
||||
// GOPROXY protocol.
|
||||
func TestLocalGoProxy(t *testing.T) {
|
||||
createRepo(t, `{"name":"local-go","package_type":"goproxy","repo_type":"local"}`)
|
||||
defer deleteRepo(t, "local-go")
|
||||
|
||||
const mod = "example.unkin.net/!hello"
|
||||
gomod := []byte("module example.unkin.net/Hello\n\ngo 1.25\n")
|
||||
var zip010 []byte
|
||||
for _, ver := range []string{"v0.1.0", "v0.2.0"} {
|
||||
dir := t.TempDir()
|
||||
os.WriteFile(filepath.Join(dir, "go.mod"), gomod, 0o600)
|
||||
os.WriteFile(filepath.Join(dir, "hello.go"), []byte("package hello\n"), 0o600)
|
||||
var buf bytes.Buffer
|
||||
if err := modzip.CreateFromDir(&buf, module.Version{Path: "example.unkin.net/Hello", Version: ver}, dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ver == "v0.1.0" {
|
||||
zip010 = buf.Bytes()
|
||||
}
|
||||
uploadFile(t, "local-go", mod+"/@v/"+ver+".zip", buf.Bytes(), "application/zip")
|
||||
uploadFile(t, "local-go", mod+"/@v/"+ver+".mod", gomod, "text/plain")
|
||||
}
|
||||
|
||||
resp, body := doRequest(t, http.MethodPut, api("/api/v2/remotes/local-go/files/"+mod+"/@v/v0.3.0.zip"), []byte("junk"), "application/zip")
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("invalid zip: status %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
for path, want := range map[string]string{
|
||||
"/@v/list": "v0.1.0\nv0.2.0\n",
|
||||
"/@latest": `{"Version":"v0.2.0"}`,
|
||||
"/@v/v0.1.0.info": `{"Version":"v0.1.0"}`,
|
||||
"/@v/v0.1.0.mod": string(gomod),
|
||||
"/@v/v0.1.0.zip": string(zip010),
|
||||
} {
|
||||
resp, body := doRequest(t, http.MethodGet, api("/api/v1/local/local-go/"+mod+path), nil, "")
|
||||
if resp.StatusCode != http.StatusOK || string(body) != want {
|
||||
t.Errorf("%s: status %d body %q", path, resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,6 @@ require (
|
||||
github.com/testcontainers/testcontainers-go/modules/redis v0.42.0
|
||||
github.com/ulikunitz/xz v0.5.16
|
||||
golang.org/x/crypto v0.54.0
|
||||
golang.org/x/mod v0.37.0
|
||||
golang.org/x/sync v0.22.0
|
||||
golang.org/x/time v0.15.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
|
||||
@@ -227,8 +227,6 @@ golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
@@ -249,8 +247,6 @@ golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
@@ -84,22 +83,7 @@ func (h *LocalHandler) uploadValidated(w http.ResponseWriter, r *http.Request, r
|
||||
return
|
||||
}
|
||||
|
||||
var body io.Reader = r.Body
|
||||
if cv, ok := prov.(provider.ContentValidator); ok {
|
||||
f, err := spoolBody(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("read upload: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer discard(f)
|
||||
if err := cv.ValidateContent(storagePath, f.Name()); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
body = f
|
||||
}
|
||||
|
||||
result, err := h.cas.Store(r.Context(), body, contentType)
|
||||
result, err := h.cas.Store(r.Context(), r.Body, contentType)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("store failed: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
@@ -126,27 +110,6 @@ func (h *LocalHandler) uploadValidated(w http.ResponseWriter, r *http.Request, r
|
||||
writeJSON(w, http.StatusCreated, uploader.UploadResponse(storagePath, result.ContentHash, result.SizeBytes))
|
||||
}
|
||||
|
||||
func spoolBody(r io.Reader) (*os.File, error) {
|
||||
f, err := os.CreateTemp("", "artifactapi-upload-*")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := io.Copy(f, r); err != nil {
|
||||
discard(f)
|
||||
return nil, err
|
||||
}
|
||||
if _, err := f.Seek(0, io.SeekStart); err != nil {
|
||||
discard(f)
|
||||
return nil, err
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func discard(f *os.File) {
|
||||
_ = f.Close()
|
||||
_ = os.Remove(f.Name())
|
||||
}
|
||||
|
||||
func (h *LocalHandler) uploadGeneric(w http.ResponseWriter, r *http.Request, remote *models.Remote, filePath string) {
|
||||
existing, err := h.db.GetLocalFile(r.Context(), remote.Name, filePath)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/database"
|
||||
_ "git.unkin.net/unkin/artifactapi/internal/provider/goproxy"
|
||||
"git.unkin.net/unkin/artifactapi/pkg/models"
|
||||
)
|
||||
|
||||
// TestLocalUploadContentRejected verifies a ContentValidator provider rejects
|
||||
// bad bytes with 400 before anything is stored.
|
||||
func TestLocalUploadContentRejected(t *testing.T) {
|
||||
if testDSN == "" {
|
||||
t.Skip("Docker unavailable")
|
||||
}
|
||||
ctx := context.Background()
|
||||
db, err := database.New(testDSN)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
const repo = "go-local-validate"
|
||||
if err := db.CreateRemote(ctx, &models.Remote{Name: repo, PackageType: models.PackageGoProxy, RepoType: models.RepoTypeLocal}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Route("/remotes/{name}/files", func(r chi.Router) {
|
||||
r.Put("/*", NewLocalHandler(db, nil).Routes().ServeHTTP)
|
||||
})
|
||||
|
||||
cases := map[string]string{
|
||||
"example.unkin.net/hello/@v/v0.1.0.zip": "not a zip",
|
||||
"example.unkin.net/hello/@v/v0.1.0.mod": "module example.unkin.net/other\n",
|
||||
"example.unkin.net/hello/@v/v0.1.0.info": `{"Version":"v9.9.9"}`,
|
||||
"example.unkin.net/Hello/@v/v0.1.0.mod": "module example.unkin.net/Hello\n",
|
||||
}
|
||||
for path, body := range cases {
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, httptest.NewRequest("PUT", "/remotes/"+repo+"/files/"+path, strings.NewReader(body)))
|
||||
if w.Code != 400 {
|
||||
t.Errorf("%s: status %d, want 400 (%s)", path, w.Code, w.Body.String())
|
||||
}
|
||||
if f, _ := db.GetLocalFile(ctx, repo, path); f != nil {
|
||||
t.Errorf("%s: rejected upload was recorded", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
package goproxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/mod/modfile"
|
||||
"golang.org/x/mod/module"
|
||||
"golang.org/x/mod/semver"
|
||||
modzip "golang.org/x/mod/zip"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/provider"
|
||||
)
|
||||
|
||||
type versionFile struct {
|
||||
module string
|
||||
version string
|
||||
ext string
|
||||
}
|
||||
|
||||
// parseVersionFile splits "<escaped module>/@v/<version>.<ext>" into its parts.
|
||||
func parseVersionFile(path string) (versionFile, error) {
|
||||
escMod, file, ok := strings.Cut(strings.TrimPrefix(path, "/"), "/@v/")
|
||||
if !ok || escMod == "" || strings.Contains(file, "/") {
|
||||
return versionFile{}, fmt.Errorf("path %q must be <module>/@v/<version>.{zip,mod,info}", path)
|
||||
}
|
||||
dot := strings.LastIndex(file, ".")
|
||||
if dot <= 0 {
|
||||
return versionFile{}, fmt.Errorf("path %q must end in .zip, .mod or .info", path)
|
||||
}
|
||||
ext := file[dot+1:]
|
||||
if ext != "zip" && ext != "mod" && ext != "info" {
|
||||
return versionFile{}, fmt.Errorf("path %q must end in .zip, .mod or .info", path)
|
||||
}
|
||||
mod, err := module.UnescapePath(escMod)
|
||||
if err != nil {
|
||||
return versionFile{}, fmt.Errorf("module path must be case-encoded (uppercase as !lower): %w", err)
|
||||
}
|
||||
ver, err := module.UnescapeVersion(file[:dot])
|
||||
if err != nil {
|
||||
return versionFile{}, err
|
||||
}
|
||||
if err := module.Check(mod, ver); err != nil {
|
||||
return versionFile{}, err
|
||||
}
|
||||
if semver.Canonical(ver) != ver {
|
||||
return versionFile{}, fmt.Errorf("version %q is not canonical semver", ver)
|
||||
}
|
||||
return versionFile{module: mod, version: ver, ext: ext}, nil
|
||||
}
|
||||
|
||||
func (v versionFile) storagePath() string {
|
||||
escMod, _ := module.EscapePath(v.module)
|
||||
escVer, _ := module.EscapeVersion(v.version)
|
||||
return escMod + "/@v/" + escVer + "." + v.ext
|
||||
}
|
||||
|
||||
func (p *Provider) ValidateUpload(filePath string) (storagePath, contentType string, err error) {
|
||||
vf, err := parseVersionFile(filePath)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
sp := vf.storagePath()
|
||||
return sp, p.ContentType(sp), nil
|
||||
}
|
||||
|
||||
func (p *Provider) ValidateContent(storagePath, localFile string) error {
|
||||
vf, err := parseVersionFile(storagePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch vf.ext {
|
||||
case "zip":
|
||||
if _, err := modzip.CheckZip(module.Version{Path: vf.module, Version: vf.version}, localFile); err != nil {
|
||||
return fmt.Errorf("invalid module zip (files must sit under %s@%s/): %w", vf.module, vf.version, err)
|
||||
}
|
||||
case "mod":
|
||||
data, err := os.ReadFile(localFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mf, err := modfile.ParseLax("go.mod", data, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid go.mod: %w", err)
|
||||
}
|
||||
if mf.Module == nil || mf.Module.Mod.Path != vf.module {
|
||||
return fmt.Errorf("go.mod must declare module %s", vf.module)
|
||||
}
|
||||
case "info":
|
||||
data, err := os.ReadFile(localFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var info struct{ Version string }
|
||||
if err := json.Unmarshal(data, &info); err != nil {
|
||||
return fmt.Errorf("invalid .info JSON: %w", err)
|
||||
}
|
||||
if info.Version != vf.version {
|
||||
return fmt.Errorf(".info Version %q does not match %s", info.Version, vf.version)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Provider) UploadResponse(storagePath, contentHash string, sizeBytes int64) map[string]any {
|
||||
vf, _ := parseVersionFile(storagePath)
|
||||
return map[string]any{
|
||||
"module": vf.module,
|
||||
"version": vf.version,
|
||||
"path": storagePath,
|
||||
"content_hash": contentHash,
|
||||
"size_bytes": sizeBytes,
|
||||
}
|
||||
}
|
||||
|
||||
// versions returns the canonical versions of escMod that have a .mod uploaded, sorted ascending.
|
||||
func versions(ctx context.Context, files provider.FileStore, repoName, escMod string) (versions []string, infos map[string]bool, err error) {
|
||||
entries, err := files.ListFilesByPrefix(ctx, repoName, escMod+"/@v/")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
infos = map[string]bool{}
|
||||
for _, e := range entries {
|
||||
vf, err := parseVersionFile(e.FilePath)
|
||||
if err != nil || vf.storagePath() != e.FilePath {
|
||||
continue
|
||||
}
|
||||
switch vf.ext {
|
||||
case "mod":
|
||||
versions = append(versions, vf.version)
|
||||
case "info":
|
||||
infos[vf.version] = true
|
||||
}
|
||||
}
|
||||
semver.Sort(versions)
|
||||
return versions, infos, nil
|
||||
}
|
||||
|
||||
func latest(vs []string) string {
|
||||
for i := len(vs) - 1; i >= 0; i-- {
|
||||
if semver.Prerelease(vs[i]) == "" {
|
||||
return vs[i]
|
||||
}
|
||||
}
|
||||
if len(vs) == 0 {
|
||||
return ""
|
||||
}
|
||||
return vs[len(vs)-1]
|
||||
}
|
||||
|
||||
func infoJSON(version string) []byte {
|
||||
b, _ := json.Marshal(map[string]string{"Version": version})
|
||||
return b
|
||||
}
|
||||
|
||||
func (p *Provider) ServeLocalIndex(w http.ResponseWriter, r *http.Request, files provider.FileStore, repoName, path string) bool {
|
||||
path = strings.TrimPrefix(path, "/")
|
||||
switch {
|
||||
case strings.HasSuffix(path, "/@v/list"), strings.HasSuffix(path, "/@latest"):
|
||||
case strings.HasSuffix(path, ".info"):
|
||||
vf, err := parseVersionFile(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
escMod, _ := module.EscapePath(vf.module)
|
||||
vs, infos, err := versions(r.Context(), files, repoName, escMod)
|
||||
if err != nil || infos[vf.version] || !slices.Contains(vs, vf.version) {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
body, err := p.GenerateLocalIndex(r.Context(), files, repoName, path)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return true
|
||||
}
|
||||
w.Header().Set("Content-Type", p.ContentType(path))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(body)
|
||||
return true
|
||||
}
|
||||
|
||||
func (p *Provider) GenerateLocalIndex(ctx context.Context, files provider.FileStore, repoName, path string) ([]byte, error) {
|
||||
path = strings.TrimPrefix(path, "/")
|
||||
if escMod, ok := strings.CutSuffix(path, "/@v/list"); ok {
|
||||
vs, _, err := versions(ctx, files, repoName, escMod)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, v := range vs {
|
||||
b.WriteString(v + "\n")
|
||||
}
|
||||
return []byte(b.String()), nil
|
||||
}
|
||||
if escMod, ok := strings.CutSuffix(path, "/@latest"); ok {
|
||||
vs, _, err := versions(ctx, files, repoName, escMod)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v := latest(vs)
|
||||
if v == "" {
|
||||
return nil, fmt.Errorf("no versions of %s", escMod)
|
||||
}
|
||||
return infoJSON(v), nil
|
||||
}
|
||||
if strings.HasSuffix(path, ".info") {
|
||||
vf, err := parseVersionFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return infoJSON(vf.version), nil
|
||||
}
|
||||
return nil, fmt.Errorf("unsupported index path: %q", path)
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
package goproxy
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/mod/module"
|
||||
modzip "golang.org/x/mod/zip"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/provider"
|
||||
)
|
||||
|
||||
type fakeFileStore struct{ paths []string }
|
||||
|
||||
func (f *fakeFileStore) ListPackages(context.Context, string) ([]string, error) { return nil, nil }
|
||||
|
||||
func (f *fakeFileStore) ListFilesByPrefix(_ context.Context, _, prefix string) ([]provider.FileEntry, error) {
|
||||
var out []provider.FileEntry
|
||||
for _, p := range f.paths {
|
||||
if strings.HasPrefix(p, prefix) {
|
||||
out = append(out, provider.FileEntry{FilePath: p, ContentHash: "sha256:x"})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func TestValidateUpload(t *testing.T) {
|
||||
p := &Provider{}
|
||||
cases := []struct {
|
||||
in, want string
|
||||
ok bool
|
||||
}{
|
||||
{"example.unkin.net/hello/@v/v0.1.0.zip", "example.unkin.net/hello/@v/v0.1.0.zip", true},
|
||||
{"/example.unkin.net/hello/@v/v0.1.0.mod", "example.unkin.net/hello/@v/v0.1.0.mod", true},
|
||||
{"github.com/!burnt!sushi/toml/@v/v1.2.0.info", "github.com/!burnt!sushi/toml/@v/v1.2.0.info", true},
|
||||
{"example.unkin.net/hello/v2/@v/v2.0.0-rc.1.zip", "example.unkin.net/hello/v2/@v/v2.0.0-rc.1.zip", true},
|
||||
{"github.com/BurntSushi/toml/@v/v1.2.0.zip", "", false},
|
||||
{"example.unkin.net/hello/@v/v0.1.zip", "", false},
|
||||
{"example.unkin.net/hello/@v/v2.0.0.zip", "", false},
|
||||
{"example.unkin.net/hello/@v/v0.1.0.tar.gz", "", false},
|
||||
{"example.unkin.net/hello/v0.1.0.zip", "", false},
|
||||
{"example.unkin.net/hello/@v/list", "", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, ct, err := p.ValidateUpload(c.in)
|
||||
if (err == nil) != c.ok {
|
||||
t.Errorf("%s: err=%v, want ok=%v", c.in, err, c.ok)
|
||||
continue
|
||||
}
|
||||
if c.ok && (got != c.want || ct == "") {
|
||||
t.Errorf("%s: got %q %q, want %q", c.in, got, ct, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeZip(t *testing.T, files map[string]string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "m.zip")
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zw := zip.NewWriter(f)
|
||||
for name, body := range files {
|
||||
w, err := zw.Create(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := w.Write([]byte(body)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "f")
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestValidateContentZip(t *testing.T) {
|
||||
p := &Provider{}
|
||||
sp := "example.unkin.net/hello/@v/v0.1.0.zip"
|
||||
|
||||
dir := t.TempDir()
|
||||
os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module example.unkin.net/hello\n"), 0o600)
|
||||
os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\n"), 0o600)
|
||||
good := filepath.Join(t.TempDir(), "good.zip")
|
||||
f, _ := os.Create(good)
|
||||
if err := modzip.CreateFromDir(f, module.Version{Path: "example.unkin.net/hello", Version: "v0.1.0"}, dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.Close()
|
||||
if err := p.ValidateContent(sp, good); err != nil {
|
||||
t.Errorf("valid zip rejected: %v", err)
|
||||
}
|
||||
|
||||
bad := map[string]map[string]string{
|
||||
"no prefix": {"go.mod": "module example.unkin.net/hello\n"},
|
||||
"wrong version": {"example.unkin.net/hello@v0.2.0/go.mod": "module example.unkin.net/hello\n"},
|
||||
"wrong module": {"example.unkin.net/other@v0.1.0/go.mod": "module example.unkin.net/other\n"},
|
||||
}
|
||||
for name, files := range bad {
|
||||
if err := p.ValidateContent(sp, writeZip(t, files)); err == nil {
|
||||
t.Errorf("%s: expected rejection", name)
|
||||
}
|
||||
}
|
||||
if err := p.ValidateContent(sp, writeFile(t, "not a zip")); err == nil {
|
||||
t.Error("non-zip accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateContentModInfo(t *testing.T) {
|
||||
p := &Provider{}
|
||||
mod := "example.unkin.net/hello/@v/v0.1.0.mod"
|
||||
info := "example.unkin.net/hello/@v/v0.1.0.info"
|
||||
cases := []struct {
|
||||
path, body string
|
||||
ok bool
|
||||
}{
|
||||
{mod, "module example.unkin.net/hello\n\ngo 1.25\n", true},
|
||||
{mod, "module example.unkin.net/other\n", false},
|
||||
{mod, "go 1.25\n", false},
|
||||
{mod, "module (\n", false},
|
||||
{info, `{"Version":"v0.1.0","Time":"2026-01-01T00:00:00Z"}`, true},
|
||||
{info, `{"Version":"v0.2.0"}`, false},
|
||||
{info, `nope`, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if err := p.ValidateContent(c.path, writeFile(t, c.body)); (err == nil) != c.ok {
|
||||
t.Errorf("%s %q: err=%v, want ok=%v", c.path, c.body, err, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func serve(t *testing.T, fs provider.FileStore, path string) (bool, int, string) {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
handled := (&Provider{}).ServeLocalIndex(rec, httptest.NewRequest("GET", "/"+path, nil), fs, "go-local", path)
|
||||
return handled, rec.Code, rec.Body.String()
|
||||
}
|
||||
|
||||
func TestServeLocalIndex(t *testing.T) {
|
||||
fs := &fakeFileStore{paths: []string{
|
||||
"example.unkin.net/hello/@v/v0.10.0.mod",
|
||||
"example.unkin.net/hello/@v/v0.10.0.zip",
|
||||
"example.unkin.net/hello/@v/v0.2.0.mod",
|
||||
"example.unkin.net/hello/@v/v0.2.0.zip",
|
||||
"example.unkin.net/hello/@v/v0.2.0.info",
|
||||
"example.unkin.net/hello/@v/v0.11.0-rc.1.mod",
|
||||
"example.unkin.net/hello/@v/v0.3.0.zip",
|
||||
"example.unkin.net/hello/v2/@v/v2.0.0.mod",
|
||||
"example.unkin.net/!pre/@v/v1.0.0-beta.mod",
|
||||
}}
|
||||
|
||||
if h, code, body := serve(t, fs, "example.unkin.net/hello/@v/list"); !h || code != 200 || body != "v0.2.0\nv0.10.0\nv0.11.0-rc.1\n" {
|
||||
t.Errorf("list: %v %d %q", h, code, body)
|
||||
}
|
||||
if h, code, body := serve(t, fs, "example.unkin.net/hello/@latest"); !h || code != 200 || body != `{"Version":"v0.10.0"}` {
|
||||
t.Errorf("latest: %v %d %q", h, code, body)
|
||||
}
|
||||
if h, _, body := serve(t, fs, "example.unkin.net/!pre/@latest"); !h || body != `{"Version":"v1.0.0-beta"}` {
|
||||
t.Errorf("prerelease-only latest: %q", body)
|
||||
}
|
||||
if h, code, _ := serve(t, fs, "example.unkin.net/missing/@latest"); !h || code != 404 {
|
||||
t.Errorf("missing latest: %v %d", h, code)
|
||||
}
|
||||
if h, code, body := serve(t, fs, "example.unkin.net/missing/@v/list"); !h || code != 200 || body != "" {
|
||||
t.Errorf("missing list: %v %d %q", h, code, body)
|
||||
}
|
||||
if h, _, body := serve(t, fs, "example.unkin.net/hello/@v/v0.10.0.info"); !h || body != `{"Version":"v0.10.0"}` {
|
||||
t.Errorf("synthesized info: %v %q", h, body)
|
||||
}
|
||||
if h, _, _ := serve(t, fs, "example.unkin.net/hello/@v/v0.2.0.info"); h {
|
||||
t.Error("uploaded .info must be served from storage")
|
||||
}
|
||||
if h, _, _ := serve(t, fs, "example.unkin.net/hello/@v/v0.3.0.info"); h {
|
||||
t.Error(".info without a .mod must not be synthesized")
|
||||
}
|
||||
if h, _, _ := serve(t, fs, "example.unkin.net/hello/@v/v0.10.0.zip"); h {
|
||||
t.Error(".zip must be served from storage")
|
||||
}
|
||||
}
|
||||
@@ -41,11 +41,6 @@ type LocalUploader interface {
|
||||
UploadResponse(storagePath, contentHash string, sizeBytes int64) map[string]any
|
||||
}
|
||||
|
||||
// ContentValidator checks an upload's bytes (spooled to localFile) before it is stored.
|
||||
type ContentValidator interface {
|
||||
ValidateContent(storagePath, localFile string) error
|
||||
}
|
||||
|
||||
type LocalIndexer interface {
|
||||
ServeLocalIndex(w http.ResponseWriter, r *http.Request, files FileStore, repoName, path string) bool
|
||||
GenerateLocalIndex(ctx context.Context, files FileStore, repoName, path string) ([]byte, error)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -19,6 +19,7 @@ func TestPackageTypeValid(t *testing.T) {
|
||||
models.PackageTerraform,
|
||||
models.PackageGoProxy,
|
||||
models.PackageCargo,
|
||||
models.PackageGems,
|
||||
models.PackageGitHubRPM,
|
||||
models.PackageGitHubDeb,
|
||||
models.PackageGitHubAlpine,
|
||||
|
||||
@@ -218,38 +218,15 @@ sudo apk add --allow-untrusted <package>`,
|
||||
];
|
||||
|
||||
case 'goproxy':
|
||||
return isLocal
|
||||
? [
|
||||
{
|
||||
title: 'Fetch private modules from this proxy',
|
||||
language: 'bash',
|
||||
code: `export GOPROXY=${url}/api/v1/local/${name},https://proxy.golang.org,direct
|
||||
export GONOSUMDB=example.unkin.net
|
||||
go get example.unkin.net/hello@latest`,
|
||||
note: 'Set GONOSUMDB (or GOPRIVATE) to your module prefix: private modules are not in sum.golang.org.',
|
||||
},
|
||||
{
|
||||
title: 'Publish a module version',
|
||||
language: 'bash',
|
||||
code: `# build the zip with golang.org/x/mod/zip (CreateFromDir), or reuse
|
||||
# the .zip/.mod/.info from $(go env GOMODCACHE)/cache/download after go mod download
|
||||
MOD=example.unkin.net/hello VER=v0.1.0
|
||||
for ext in zip mod info; do
|
||||
curl -fsSL --upload-file $VER.$ext \\
|
||||
${url}/api/v2/remotes/${name}/files/$MOD/@v/$VER.$ext
|
||||
done`,
|
||||
note: 'Paths use the case-encoded module path (uppercase as !lower). Zips must hold every file under module@version/ and are rejected otherwise; .info is optional.',
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
title: 'Point the Go module proxy here',
|
||||
language: 'bash',
|
||||
code: `export GOPROXY=${proxy}
|
||||
return [
|
||||
{
|
||||
title: 'Point the Go module proxy here',
|
||||
language: 'bash',
|
||||
code: `export GOPROXY=${proxy}
|
||||
go mod download`,
|
||||
note: 'Append ,direct to fall back to VCS for modules this proxy does not cover.',
|
||||
},
|
||||
];
|
||||
note: 'Append ,direct to fall back to VCS for modules this proxy does not cover.',
|
||||
},
|
||||
];
|
||||
|
||||
case 'cargo':
|
||||
return [
|
||||
@@ -266,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 [
|
||||
{
|
||||
|
||||
@@ -18,6 +18,7 @@ const typeColors: Record<string, 'blue' | 'green' | 'yellow' | 'red' | 'default'
|
||||
terraform: 'blue',
|
||||
goproxy: 'green',
|
||||
cargo: 'yellow',
|
||||
gems: 'red',
|
||||
};
|
||||
|
||||
export function Locals() {
|
||||
|
||||
@@ -18,6 +18,7 @@ const typeColors: Record<string, 'blue' | 'green' | 'yellow' | 'red' | 'default'
|
||||
terraform: 'blue',
|
||||
goproxy: 'green',
|
||||
cargo: 'yellow',
|
||||
gems: 'red',
|
||||
};
|
||||
|
||||
export function Remotes() {
|
||||
|
||||
Reference in New Issue
Block a user