From a51323c725094e0337b04f83a37ecfb7c03b163e Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Fri, 16 Oct 2026 23:32:48 +1100 Subject: [PATCH 1/3] Add goproxy local repositories for private Go modules --- README.md | 21 +++ e2e-docker/goproxy_local_test.go | 57 ++++++ go.mod | 1 + go.sum | 4 + internal/api/v2/local.go | 39 ++++- internal/api/v2/local_validate_test.go | 55 ++++++ internal/provider/goproxy/local.go | 222 ++++++++++++++++++++++++ internal/provider/goproxy/local_test.go | 198 +++++++++++++++++++++ internal/provider/provider.go | 5 + ui/src/components/UsageInstructions.tsx | 39 ++++- 10 files changed, 632 insertions(+), 9 deletions(-) create mode 100644 e2e-docker/goproxy_local_test.go create mode 100644 internal/api/v2/local_validate_test.go create mode 100644 internal/provider/goproxy/local.go create mode 100644 internal/provider/goproxy/local_test.go diff --git a/README.md b/README.md index 660a658..cd926b1 100644 --- a/README.md +++ b/README.md @@ -261,6 +261,27 @@ 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/.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 diff --git a/e2e-docker/goproxy_local_test.go b/e2e-docker/goproxy_local_test.go new file mode 100644 index 0000000..8e190d6 --- /dev/null +++ b/e2e-docker/goproxy_local_test.go @@ -0,0 +1,57 @@ +//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) + } + } +} diff --git a/go.mod b/go.mod index 73858b7..085b049 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,7 @@ 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 diff --git a/go.sum b/go.sum index c0b4cad..a77e077 100644 --- a/go.sum +++ b/go.sum @@ -227,6 +227,8 @@ 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= @@ -247,6 +249,8 @@ 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= diff --git a/internal/api/v2/local.go b/internal/api/v2/local.go index 52f2849..da5c947 100644 --- a/internal/api/v2/local.go +++ b/internal/api/v2/local.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + "os" "github.com/go-chi/chi/v5" @@ -83,7 +84,22 @@ func (h *LocalHandler) uploadValidated(w http.ResponseWriter, r *http.Request, r return } - result, err := h.cas.Store(r.Context(), r.Body, contentType) + 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) if err != nil { http.Error(w, fmt.Sprintf("store failed: %v", err), http.StatusInternalServerError) return @@ -110,6 +126,27 @@ 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 { diff --git a/internal/api/v2/local_validate_test.go b/internal/api/v2/local_validate_test.go new file mode 100644 index 0000000..955f018 --- /dev/null +++ b/internal/api/v2/local_validate_test.go @@ -0,0 +1,55 @@ +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) + } + } +} diff --git a/internal/provider/goproxy/local.go b/internal/provider/goproxy/local.go new file mode 100644 index 0000000..e7a4a96 --- /dev/null +++ b/internal/provider/goproxy/local.go @@ -0,0 +1,222 @@ +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 "/@v/." 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 /@v/.{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) +} diff --git a/internal/provider/goproxy/local_test.go b/internal/provider/goproxy/local_test.go new file mode 100644 index 0000000..bcadd37 --- /dev/null +++ b/internal/provider/goproxy/local_test.go @@ -0,0 +1,198 @@ +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") + } +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 8de9201..947ea5f 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -41,6 +41,11 @@ 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) diff --git a/ui/src/components/UsageInstructions.tsx b/ui/src/components/UsageInstructions.tsx index 228a924..9a2f593 100644 --- a/ui/src/components/UsageInstructions.tsx +++ b/ui/src/components/UsageInstructions.tsx @@ -218,15 +218,38 @@ sudo apk add --allow-untrusted `, ]; case 'goproxy': - return [ - { - title: 'Point the Go module proxy here', - language: 'bash', - code: `export GOPROXY=${proxy} + 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} 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 [ -- 2.47.3 From c6f7eb3eb70241a91ce84c1bbfc784f8b2b31dd3 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sun, 18 Oct 2026 21:02:50 +1100 Subject: [PATCH 2/3] Add goproxy virtual repositories --- README.md | 2 +- docs/goproxy/virtual.md | 30 ++++ e2e-docker/README.md | 4 +- e2e-docker/goproxy_virtual_test.go | 81 +++++++++ internal/api/v1/goproxy_virtual.go | 33 ++++ internal/api/v1/proxy.go | 4 + internal/provider/goproxy/local.go | 5 +- internal/virtual/engine.go | 6 + internal/virtual/goproxy_merger.go | 208 ++++++++++++++++++++++++ internal/virtual/goproxy_merger_test.go | 129 +++++++++++++++ 10 files changed, 498 insertions(+), 4 deletions(-) create mode 100644 docs/goproxy/virtual.md create mode 100644 e2e-docker/goproxy_virtual_test.go create mode 100644 internal/api/v1/goproxy_virtual.go create mode 100644 internal/virtual/goproxy_merger.go create mode 100644 internal/virtual/goproxy_merger_test.go diff --git a/README.md b/README.md index 71f2e69..fa720b8 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Each type's paths, Terraform examples and local/virtual behaviour are in `docs/< - `docker`: [remote](docs/docker/remote.md), [local](docs/docker/local.md) - `generic`: [remote](docs/generic/remote.md) - `gems`: [remote](docs/gems/remote.md), [local](docs/gems/local.md), [virtual](docs/gems/virtual.md) -- `goproxy`: [remote](docs/goproxy/remote.md), [local](docs/goproxy/local.md) +- `goproxy`: [remote](docs/goproxy/remote.md), [local](docs/goproxy/local.md), [virtual](docs/goproxy/virtual.md) - `helm`: [remote](docs/helm/remote.md), [local](docs/helm/local.md), [virtual](docs/helm/virtual.md) - `npm`: [remote](docs/npm/remote.md), [local](docs/npm/local.md), [virtual](docs/npm/virtual.md) - `nuget`: [remote](docs/nuget/remote.md) diff --git a/docs/goproxy/virtual.md b/docs/goproxy/virtual.md new file mode 100644 index 0000000..4a5d2e6 --- /dev/null +++ b/docs/goproxy/virtual.md @@ -0,0 +1,30 @@ +# `goproxy` virtual + +A goproxy virtual serves one GOPROXY over local and remote goproxy members: +`GOPROXY=https://artifactapi.example.com/api/v1/virtual/`. + +- `/@v/list` is the union of member versions, sorted by semver. +- `/@latest` is the highest release in that union, else the highest + prerelease; with no listed versions it falls back to the first member that + answers `@latest`. +- `@latest` and `@v/.{info,mod,zip}` 302 to the first member, in + member order, that lists the version (or, for unlisted pseudo-versions, + serves its `.info`). Redirects are root-relative and `no-store`. +- Module paths are case-encoded (uppercase as `!lower`). +- A member error fails the request with 502; a member answering 404 or 410 is + skipped. Merged lists are cached for 60s. + +```hcl +resource "artifactapi_virtual" "go" { + name = "go" + package_type = "goproxy" + members = [artifactapi_local_goproxy.internal.name, artifactapi_remote_goproxy.goproxy.name] +} +``` + +```sh +export GOPROXY=https://artifactapi.example.com/api/v1/virtual/go GONOSUMDB=example.unkin.net +go get example.unkin.net/hello@latest golang.org/x/text@latest +``` + +Publish to the local member directly; the virtual is read-only. diff --git a/e2e-docker/README.md b/e2e-docker/README.md index 53658ca..c7d2342 100644 --- a/e2e-docker/README.md +++ b/e2e-docker/README.md @@ -38,7 +38,9 @@ already-running stack. client (merged versions, install through the member redirect); gems compact index merge over two locals via stock `ruby:3.3` bundler (member order wins, downloads 302 to the owning member); ansible over two locals via stock - `ansible-galaxy` (install through the virtual, download 302 to the owner). + `ansible-galaxy` (install through the virtual, download 302 to the owner); + goproxy over two locals via a stock `golang:1.25` client (merged versions, + member order wins, version files 302 to the owning member). - **Mirrorlist** — an rpm remote with a `mirrorlist` of extra upstream mirrors (pool = `base_url` + `mirrorlist`): round-robin distribution across both mirrors (constant-body `mockupstreama` / `mockupstreamb`), failover past a dead primary, diff --git a/e2e-docker/goproxy_virtual_test.go b/e2e-docker/goproxy_virtual_test.go new file mode 100644 index 0000000..075fd64 --- /dev/null +++ b/e2e-docker/goproxy_virtual_test.go @@ -0,0 +1,81 @@ +//go:build dockere2e + +package e2edocker + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "golang.org/x/mod/module" + modzip "golang.org/x/mod/zip" +) + +// TestVirtualGoProxy publishes to two local goproxy repos and drives a stock go +// client through a virtual over both: merged versions, member order winning a +// duplicate version, and version files redirected to the owning member. +func TestVirtualGoProxy(t *testing.T) { + network := os.Getenv("COMPOSE_NETWORK") + internal := os.Getenv("ARTIFACTAPI_INTERNAL") + if network == "" || internal == "" { + t.Skip("COMPOSE_NETWORK/ARTIFACTAPI_INTERNAL not set; run via scripts/docker-e2e.sh") + } + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker not available on the test host") + } + + id := time.Now().UnixNano() + a, b, virt := fmt.Sprintf("e2e-go-a-%d", id), fmt.Sprintf("e2e-go-b-%d", id), fmt.Sprintf("e2e-go-v-%d", id) + for _, name := range []string{a, b} { + createRepo(t, fmt.Sprintf(`{"name":%q,"package_type":"goproxy","repo_type":"local"}`, name)) + defer deleteRepo(t, name) + } + createVirtual(t, fmt.Sprintf(`{"name":%q,"package_type":"goproxy","members":[%q,%q]}`, virt, a, b)) + defer deleteVirtual(t, virt) + + const mod = "example.unkin.net/hello" + gomod := []byte("module " + mod + "\n\ngo 1.25\n") + for _, p := range [][2]string{{a, "v0.1.0"}, {b, "v0.1.0"}, {b, "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\nconst Greeting = \""+p[0]+":"+p[1]+"\"\n"), 0o600) + var buf bytes.Buffer + if err := modzip.CreateFromDir(&buf, module.Version{Path: mod, Version: p[1]}, dir); err != nil { + t.Fatal(err) + } + uploadFile(t, p[0], mod+"/@v/"+p[1]+".zip", buf.Bytes(), "application/zip") + uploadFile(t, p[0], mod+"/@v/"+p[1]+".mod", gomod, "text/plain") + } + + base := strings.TrimRight(internal, "/") + script := `set -euo pipefail +export GOPROXY="$BASE/api/v1/virtual/$V" GONOSUMDB=example.unkin.net GOFLAGS=-mod=mod GOTOOLCHAIN=local +mkdir -p /w && cd /w +printf 'module consumer\n\ngo 1.25\n' > go.mod +printf 'package main\n\nimport "example.unkin.net/hello"\n\nfunc main() { println("RUN " + hello.Greeting) }\n' > main.go +echo "VERSIONS $(go list -m -versions example.unkin.net/hello)" +go get example.unkin.net/hello@latest && go run . 2>&1 +go get example.unkin.net/hello@v0.1.0 && go run . 2>&1 +echo "REDIRECT $(curl -s -o /dev/null -w '%{redirect_url}' "$GOPROXY/example.unkin.net/hello/@v/v0.1.0.zip")"` + + out, err := exec.Command("docker", "run", "--rm", "--network", network, + "-e", "BASE="+base, "-e", "V="+virt, "golang:1.25", "bash", "-c", script).CombinedOutput() + if err != nil { + t.Fatalf("go run failed: %v\n%s", err, out) + } + for _, want := range []string{ + "VERSIONS example.unkin.net/hello v0.1.0 v0.2.0", + "RUN " + b + ":v0.2.0", + "RUN " + a + ":v0.1.0", + "REDIRECT " + base + "/api/v1/local/" + a + "/example.unkin.net/hello/@v/v0.1.0.zip", + } { + if !strings.Contains(string(out), want) { + t.Fatalf("missing %q in go output:\n%s", want, out) + } + } +} diff --git a/internal/api/v1/goproxy_virtual.go b/internal/api/v1/goproxy_virtual.go new file mode 100644 index 0000000..55affc1 --- /dev/null +++ b/internal/api/v1/goproxy_virtual.go @@ -0,0 +1,33 @@ +package v1 + +import ( + "errors" + "log/slog" + "net/http" + + "git.unkin.net/unkin/artifactapi/internal/virtual" + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +// serveVirtualGoProxy serves the merged @v/list and 302s @latest and version +// files to the member owning that version. +func (h *ProxyHandler) serveVirtualGoProxy(w http.ResponseWriter, r *http.Request, virt models.Virtual, path string) { + body, loc, err := h.virtualEngine.GoProxy(r.Context(), virt, path, r.URL.RawQuery) + switch { + case errors.Is(err, virtual.ErrNotFound): + http.Error(w, "not found", http.StatusNotFound) + case errors.Is(err, virtual.ErrBadPath): + http.Error(w, err.Error(), http.StatusBadRequest) + case err != nil: + slog.Error("virtual goproxy fetch failed", "virtual", virt.Name, "path", path, "error", err) + http.Error(w, "bad gateway", http.StatusBadGateway) + case loc != "": + w.Header().Set("Cache-Control", "no-store") + http.Redirect(w, r, loc, http.StatusFound) + default: + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("X-Artifact-Source", "virtual") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(body) + } +} diff --git a/internal/api/v1/proxy.go b/internal/api/v1/proxy.go index 5ec1741..4f1d145 100644 --- a/internal/api/v1/proxy.go +++ b/internal/api/v1/proxy.go @@ -215,6 +215,10 @@ func (h *ProxyHandler) handleVirtual(w http.ResponseWriter, r *http.Request) { h.serveVirtualAnsible(w, r, *virt, path, proxyBaseURL) return } + if virt.PackageType == models.PackageGoProxy { + h.serveVirtualGoProxy(w, r, *virt, path) + return + } loc, ok, err := h.virtualEngine.MemberRedirect(r.Context(), *virt, path, r.URL.RawQuery, proxyBaseURL) if err != nil { diff --git a/internal/provider/goproxy/local.go b/internal/provider/goproxy/local.go index e7a4a96..c887141 100644 --- a/internal/provider/goproxy/local.go +++ b/internal/provider/goproxy/local.go @@ -141,7 +141,8 @@ func versions(ctx context.Context, files provider.FileStore, repoName, escMod st return versions, infos, nil } -func latest(vs []string) string { +// Latest is the highest release in sorted vs, else the highest prerelease. +func Latest(vs []string) string { for i := len(vs) - 1; i >= 0; i-- { if semver.Prerelease(vs[i]) == "" { return vs[i] @@ -205,7 +206,7 @@ func (p *Provider) GenerateLocalIndex(ctx context.Context, files provider.FileSt if err != nil { return nil, err } - v := latest(vs) + v := Latest(vs) if v == "" { return nil, fmt.Errorf("no versions of %s", escMod) } diff --git a/internal/virtual/engine.go b/internal/virtual/engine.go index ed332c9..800b2a9 100644 --- a/internal/virtual/engine.go +++ b/internal/virtual/engine.go @@ -43,6 +43,8 @@ type Engine struct { AnsibleLocal func(ctx context.Context, repo, path string) ([]byte, error) ansibleDoc func(ctx context.Context, remote models.Remote, path string) ([]byte, error) + goMember func(ctx context.Context, name, path string) (*GoMember, error) + mergeTTL time.Duration sf singleflight.Group @@ -74,6 +76,7 @@ func NewEngine(db *database.DB, proxyEngine *proxy.Engine) *Engine { e.npmMember = e.fetchNPMMember e.gemsMember = e.fetchGemsMember e.ansibleDoc = e.ansibleMemberDoc + e.goMember = e.fetchGoMember return e } @@ -428,6 +431,9 @@ func (e *Engine) fetchMemberPath(ctx context.Context, remote models.Remote, path if serve != nil { rec := httptest.NewRecorder() if serve(rec, httptest.NewRequestWithContext(ctx, http.MethodGet, "/"+path, nil)) { + if rec.Code == http.StatusNotFound || rec.Code == http.StatusGone { + return nil, fmt.Errorf("%s/%s: %w", remote.Name, path, ErrNotFound) + } if rec.Code != http.StatusOK { return nil, fmt.Errorf("%s/%s: status %d", remote.Name, path, rec.Code) } diff --git a/internal/virtual/goproxy_merger.go b/internal/virtual/goproxy_merger.go new file mode 100644 index 0000000..edb83b9 --- /dev/null +++ b/internal/virtual/goproxy_merger.go @@ -0,0 +1,208 @@ +package virtual + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + + "golang.org/x/mod/module" + "golang.org/x/mod/semver" + + "git.unkin.net/unkin/artifactapi/internal/provider/goproxy" + "git.unkin.net/unkin/artifactapi/internal/proxy" + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +const maxGoModules = 1024 + +// GoMember is one member's response for a module path; Body is nil when the +// member does not have it (404/410). +type GoMember struct { + RemoteName string + Body []byte +} + +// GoDoc is a goproxy virtual's merged @v/list for one module. Owners maps each +// version to the first member listing it. +type GoDoc struct { + Body []byte + Owners map[string]string + Latest string +} + +func (d *GoDoc) index() []byte { return d.Body } +func (d *GoDoc) files() map[string][]byte { return nil } + +// MergeGoList unions member @v/list documents, sorted by semver; the first +// member listing a version owns it. ErrNotFound means no member has the module. +func MergeGoList(members []GoMember) (*GoDoc, error) { + d := &GoDoc{Owners: map[string]string{}} + found := false + var vs []string + for _, m := range members { + if m.Body == nil { + continue + } + found = true + for line := range strings.Lines(string(m.Body)) { + f := strings.Fields(line) + if len(f) == 0 || !semver.IsValid(f[0]) { + continue + } + if _, dup := d.Owners[f[0]]; dup { + continue + } + d.Owners[f[0]] = m.RemoteName + vs = append(vs, f[0]) + } + } + if !found { + return nil, ErrNotFound + } + semver.Sort(vs) + for _, v := range vs { + d.Body = append(d.Body, v+"\n"...) + } + if d.Body == nil { + d.Body = []byte{} + } + d.Latest = goproxy.Latest(vs) + return d, nil +} + +// GoProxy serves a goproxy virtual path: the merged /@v/list body, or a +// root-relative redirect for @latest and @v/.{info,mod,zip} to the +// first member, in member order, that has that version. +func (e *Engine) GoProxy(ctx context.Context, virt models.Virtual, path, rawQuery string) (body []byte, loc string, err error) { + escMod, rest, ok := strings.Cut(path, "/@") + if !ok { + return nil, "", ErrNotFound + } + if _, err := module.UnescapePath(escMod); err != nil { + return nil, "", ErrNotFound + } + if rest == "v/list" { + d, err := e.goList(ctx, virt, escMod) + if err != nil { + return nil, "", err + } + return d.Body, "", nil + } + + var target, owner string + var d *GoDoc + switch { + case rest == "latest": + if d, err = e.goList(ctx, virt, escMod); err != nil { + return nil, "", err + } + if d.Latest == "" { + target = path + owner, err = e.goProbe(ctx, virt, target) + break + } + escVer, _ := module.EscapeVersion(d.Latest) + target, owner = escMod+"/@v/"+escVer+".info", d.Owners[d.Latest] + case strings.HasPrefix(rest, "v/"): + escVer, ext, ok := cutGoExt(rest[len("v/"):]) + if !ok { + return nil, "", ErrNotFound + } + ver, verr := module.UnescapeVersion(escVer) + if verr != nil { + return nil, "", ErrNotFound + } + if d, err = e.goList(ctx, virt, escMod); err != nil && !errors.Is(err, ErrNotFound) { + return nil, "", err + } + if d != nil { + owner = d.Owners[ver] + } + if owner == "" { + owner, err = e.goProbe(ctx, virt, escMod+"/@v/"+escVer+".info") + } + target = escMod + "/@v/" + escVer + "." + ext + default: + return nil, "", ErrNotFound + } + if err != nil { + return nil, "", err + } + loc, ok, err = e.memberLocation(ctx, owner, target, "", "") + if err != nil || !ok { + return nil, "", errors.Join(err, ErrNotFound) + } + // module paths and versions are validated, so the only escape is the case-encoding '!' + loc = strings.ReplaceAll(loc, "%21", "!") + if rawQuery != "" { + loc += "?" + rawQuery + } + return nil, loc, nil +} + +func cutGoExt(file string) (escVer, ext string, ok bool) { + dot := strings.LastIndex(file, ".") + if dot <= 0 || strings.Contains(file, "/") { + return "", "", false + } + switch ext = file[dot+1:]; ext { + case "info", "mod", "zip": + return file[:dot], ext, true + } + return "", "", false +} + +func (e *Engine) goList(ctx context.Context, virt models.Virtual, escMod string) (*GoDoc, error) { + prefix := "goproxy\x00" + virt.Name + "\x00" + inserted := false + repo, err := e.merged(ctx, prefix+escMod, func(ctx context.Context) (mergedRepo, error) { + inserted = true + members, err := fetchMembers(virt, func(name string) (*GoMember, error) { return e.goMember(ctx, name, escMod+"/@v/list") }) + if err != nil { + return nil, err + } + return MergeGoList(members) + }) + if err != nil { + return nil, err + } + if inserted { + e.evictOldest(prefix, maxGoModules) + } + return repo.(*GoDoc), nil +} + +// goProbe returns the first member, in member order, that serves path. +func (e *Engine) goProbe(ctx context.Context, virt models.Virtual, path string) (string, error) { + for _, name := range virt.Members { + m, err := e.goMember(ctx, name, path) + if err != nil { + return "", fmt.Errorf("virtual %q: member %q: %v", virt.Name, name, err) + } + if m.Body != nil { + return name, nil + } + } + return "", ErrNotFound +} + +func (e *Engine) fetchGoMember(ctx context.Context, name, path string) (*GoMember, error) { + remote, err := e.getRemote(ctx, name) + if err != nil { + return nil, fmt.Errorf("remote %q: %w", name, err) + } + body, err := e.fetchMemberPath(ctx, *remote, path) + var pe *proxy.ProxyError + if errors.Is(err, ErrNotFound) || errors.As(err, &pe) && (pe.Status == http.StatusNotFound || pe.Status == http.StatusGone) { + return &GoMember{RemoteName: name}, nil + } + if err != nil { + return nil, err + } + if body == nil { + body = []byte{} + } + return &GoMember{RemoteName: name, Body: body}, nil +} diff --git a/internal/virtual/goproxy_merger_test.go b/internal/virtual/goproxy_merger_test.go new file mode 100644 index 0000000..2796aff --- /dev/null +++ b/internal/virtual/goproxy_merger_test.go @@ -0,0 +1,129 @@ +package virtual + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +func TestMergeGoList(t *testing.T) { + d, err := MergeGoList([]GoMember{ + {RemoteName: "internal", Body: []byte("v0.2.0\nv0.1.0\n")}, + {RemoteName: "absent"}, + {RemoteName: "goproxy", Body: []byte("v0.1.0 extra\nv1.0.0-rc.1\n\nnot-a-version\nv0.3.0\n")}, + }) + if err != nil { + t.Fatal(err) + } + if want := "v0.1.0\nv0.2.0\nv0.3.0\nv1.0.0-rc.1\n"; string(d.Body) != want { + t.Fatalf("list = %q, want %q", d.Body, want) + } + if d.Latest != "v0.3.0" { + t.Fatalf("latest = %q, want the highest release over the prerelease", d.Latest) + } + for v, owner := range map[string]string{"v0.1.0": "internal", "v0.2.0": "internal", "v0.3.0": "goproxy", "v1.0.0-rc.1": "goproxy"} { + if d.Owners[v] != owner { + t.Errorf("owner of %s = %q, want %q", v, d.Owners[v], owner) + } + } + + d, _ = MergeGoList([]GoMember{{RemoteName: "a", Body: []byte("v1.0.0-beta\nv1.0.0-alpha\n")}}) + if d.Latest != "v1.0.0-beta" { + t.Fatalf("latest with only prereleases = %q", d.Latest) + } + if d, err := MergeGoList([]GoMember{{RemoteName: "a", Body: []byte{}}}); err != nil || string(d.Body) != "" || d.Latest != "" { + t.Fatalf("empty list must merge to an empty 200 body: %v", err) + } + if _, err := MergeGoList([]GoMember{{RemoteName: "a"}, {RemoteName: "b"}}); !errors.Is(err, ErrNotFound) { + t.Fatalf("want ErrNotFound, got %v", err) + } +} + +func goEngine(docs map[string]string, fail string) (*Engine, map[string]int) { + var mu sync.Mutex + asked := map[string]int{} + return &Engine{ + mergeTTL: time.Minute, + getRemote: func(_ context.Context, name string) (*models.Remote, error) { + rt := models.RepoTypeRemote + if name == "internal" { + rt = models.RepoTypeLocal + } + return &models.Remote{Name: name, RepoType: rt}, nil + }, + goMember: func(_ context.Context, name, path string) (*GoMember, error) { + mu.Lock() + asked[name+" "+path]++ + mu.Unlock() + if name == fail { + return nil, errors.New("upstream down") + } + if d, ok := docs[name+" "+path]; ok { + return &GoMember{RemoteName: name, Body: []byte(d)}, nil + } + return &GoMember{RemoteName: name}, nil + }, + }, asked +} + +func goVirt() models.Virtual { + return models.Virtual{Name: "go", PackageType: models.PackageGoProxy, Members: []string{"internal", "goproxy"}} +} + +func TestGoProxyRedirectsToFirstOwner(t *testing.T) { + e, asked := goEngine(map[string]string{ + "internal example.unkin.net/!hello/@v/list": "v0.1.0\nv0.2.0\n", + "goproxy example.unkin.net/!hello/@v/list": "v0.2.0\nv0.3.0-rc.1\n", + "goproxy golang.org/x/text/@v/list": "v0.1.0\n", + "goproxy example.unkin.net/!hello/@v/v0.0.0-20260101-abcdef.info": "{}", + "goproxy example.com/notags/@v/list": "", + "goproxy example.com/notags/@latest": "{}", + }, "") + virt := goVirt() + body, loc, err := e.GoProxy(context.Background(), virt, "example.unkin.net/!hello/@v/list", "") + if err != nil || loc != "" || string(body) != "v0.1.0\nv0.2.0\nv0.3.0-rc.1\n" { + t.Fatalf("list = %q %q %v", body, loc, err) + } + for path, want := range map[string]string{ + "example.unkin.net/!hello/@v/v0.2.0.zip": "/api/v1/local/internal/example.unkin.net/!hello/@v/v0.2.0.zip", + "example.unkin.net/!hello/@v/v0.3.0-rc.1.mod": "/api/v1/remote/goproxy/example.unkin.net/!hello/@v/v0.3.0-rc.1.mod", + "example.unkin.net/!hello/@latest": "/api/v1/local/internal/example.unkin.net/!hello/@v/v0.2.0.info", + "example.unkin.net/!hello/@v/v0.0.0-20260101-abcdef.zip": "/api/v1/remote/goproxy/example.unkin.net/!hello/@v/v0.0.0-20260101-abcdef.zip", + "golang.org/x/text/@v/v0.1.0.info": "/api/v1/remote/goproxy/golang.org/x/text/@v/v0.1.0.info", + "example.com/notags/@latest": "/api/v1/remote/goproxy/example.com/notags/@latest", + } { + _, loc, err := e.GoProxy(context.Background(), virt, path, "") + if err != nil || loc != want { + t.Errorf("%s -> %q %v, want %q", path, loc, err, want) + } + } + for _, path := range []string{ + "example.unkin.net/!hello/@v/v9.9.9.zip", + "example.unkin.net/Hello/@v/list", + "example.unkin.net/!hello/@v/v0.1.0.tar", + "example.unkin.net/!hello/@v/../v0.1.0.zip", + "example.unkin.net/!hello", + "missing.example/mod/@v/list", + "missing.example/mod/@latest", + } { + if _, _, err := e.GoProxy(context.Background(), virt, path, ""); !errors.Is(err, ErrNotFound) { + t.Errorf("%s: want ErrNotFound, got %v", path, err) + } + } + if n := asked["internal example.unkin.net/!hello/@v/list"]; n != 1 { + t.Fatalf("merged list must be cached, internal asked %d times", n) + } +} + +func TestGoProxyFailsClosed(t *testing.T) { + e, _ := goEngine(map[string]string{"internal example.unkin.net/hello/@v/list": "v0.1.0\n"}, "goproxy") + for _, path := range []string{"example.unkin.net/hello/@v/list", "example.unkin.net/hello/@latest", "example.unkin.net/hello/@v/v0.1.0.zip"} { + if _, _, err := e.GoProxy(context.Background(), goVirt(), path, ""); err == nil || errors.Is(err, ErrNotFound) { + t.Errorf("%s: want member error, got %v", path, err) + } + } +} -- 2.47.3 From ac6e1bf79804f0871412a79dd6a898023a57b348 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sun, 18 Oct 2026 21:16:04 +1100 Subject: [PATCH 3/3] Key goproxy merge cache on owners and cache module misses --- internal/virtual/goproxy_merger.go | 34 ++++++++++++---- internal/virtual/goproxy_merger_test.go | 54 +++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 7 deletions(-) diff --git a/internal/virtual/goproxy_merger.go b/internal/virtual/goproxy_merger.go index edb83b9..f502631 100644 --- a/internal/virtual/goproxy_merger.go +++ b/internal/virtual/goproxy_merger.go @@ -4,7 +4,9 @@ import ( "context" "errors" "fmt" + "maps" "net/http" + "slices" "strings" "golang.org/x/mod/module" @@ -25,14 +27,25 @@ type GoMember struct { } // GoDoc is a goproxy virtual's merged @v/list for one module. Owners maps each -// version to the first member listing it. +// version to the first member listing it; missing caches that no member has it. type GoDoc struct { - Body []byte - Owners map[string]string - Latest string + Body []byte + Owners map[string]string + Latest string + missing bool +} + +func (d *GoDoc) index() []byte { + if d.missing { + return nil + } + b := append(slices.Clone(d.Body), 0) + for _, v := range slices.Sorted(maps.Keys(d.Owners)) { + b = append(b, v+"="+d.Owners[v]+"\n"...) + } + return b } -func (d *GoDoc) index() []byte { return d.Body } func (d *GoDoc) files() map[string][]byte { return nil } // MergeGoList unions member @v/list documents, sorted by semver; the first @@ -163,7 +176,11 @@ func (e *Engine) goList(ctx context.Context, virt models.Virtual, escMod string) if err != nil { return nil, err } - return MergeGoList(members) + d, err := MergeGoList(members) + if errors.Is(err, ErrNotFound) { + return &GoDoc{missing: true}, nil + } + return d, err }) if err != nil { return nil, err @@ -171,7 +188,10 @@ func (e *Engine) goList(ctx context.Context, virt models.Virtual, escMod string) if inserted { e.evictOldest(prefix, maxGoModules) } - return repo.(*GoDoc), nil + if d := repo.(*GoDoc); !d.missing { + return d, nil + } + return nil, ErrNotFound } // goProbe returns the first member, in member order, that serves path. diff --git a/internal/virtual/goproxy_merger_test.go b/internal/virtual/goproxy_merger_test.go index 2796aff..0d99d19 100644 --- a/internal/virtual/goproxy_merger_test.go +++ b/internal/virtual/goproxy_merger_test.go @@ -7,6 +7,8 @@ import ( "testing" "time" + "git.unkin.net/unkin/artifactapi/internal/database" + "git.unkin.net/unkin/artifactapi/internal/testsupport" "git.unkin.net/unkin/artifactapi/pkg/models" ) @@ -117,6 +119,31 @@ func TestGoProxyRedirectsToFirstOwner(t *testing.T) { if n := asked["internal example.unkin.net/!hello/@v/list"]; n != 1 { t.Fatalf("merged list must be cached, internal asked %d times", n) } + if n := asked["goproxy missing.example/mod/@v/list"]; n != 1 { + t.Fatalf("a module no member has must be cached, goproxy asked %d times", n) + } +} + +func TestGoProxyFollowsOwnerChange(t *testing.T) { + docs := map[string]string{"internal example.com/mod/@v/list": "v0.1.0\n"} + e, _ := goEngine(docs, "") + e.mergeTTL = 0 + zip := func() string { + t.Helper() + _, loc, err := e.GoProxy(context.Background(), goVirt(), "example.com/mod/@v/v0.1.0.zip", "") + if err != nil { + t.Fatal(err) + } + return loc + } + if loc := zip(); loc != "/api/v1/local/internal/example.com/mod/@v/v0.1.0.zip" { + t.Fatalf("before move: %q", loc) + } + delete(docs, "internal example.com/mod/@v/list") + docs["goproxy example.com/mod/@v/list"] = "v0.1.0\n" + if loc := zip(); loc != "/api/v1/remote/goproxy/example.com/mod/@v/v0.1.0.zip" { + t.Fatalf("same list, new owner: redirect still %q", loc) + } } func TestGoProxyFailsClosed(t *testing.T) { @@ -127,3 +154,30 @@ func TestGoProxyFailsClosed(t *testing.T) { } } } + +func TestGoProxyLocalMemberNotFound(t *testing.T) { + ctx := context.Background() + dsn, terminate, err := testsupport.StartPostgres(ctx) + if err != nil { + t.Skip("Docker unavailable") + } + defer terminate() + db, err := database.New(dsn) + if err != nil { + t.Fatal(err) + } + defer db.Close() + local := models.Remote{Name: "internal", PackageType: models.PackageGoProxy, RepoType: models.RepoTypeLocal} + if err := db.CreateRemote(ctx, &local); err != nil { + t.Fatal(err) + } + + e := NewEngine(db, nil) + if _, err := e.fetchMemberPath(ctx, local, "example.com/mod/@latest"); !errors.Is(err, ErrNotFound) { + t.Fatalf("local 404 must be ErrNotFound, got %v", err) + } + virt := models.Virtual{Name: "go", PackageType: models.PackageGoProxy, Members: []string{"internal"}} + if _, _, err := e.GoProxy(ctx, virt, "example.com/mod/@latest", ""); !errors.Is(err, ErrNotFound) { + t.Fatalf("module only absent from a local member must be not-found, got %v", err) + } +} -- 2.47.3