Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e52f2c7481 | |||
| 2747600fb7 | |||
| fa53bc7ded | |||
| 732403cb23 | |||
| fcc2672c4b | |||
| 4649878a6d | |||
| 29e4777eec |
@@ -33,11 +33,11 @@ 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)
|
||||
- `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)
|
||||
- `puppet`: [remote](docs/puppet/remote.md), [local](docs/puppet/local.md)
|
||||
- `puppet`: [remote](docs/puppet/remote.md), [local](docs/puppet/local.md), [virtual](docs/puppet/virtual.md)
|
||||
- `pypi`: [remote](docs/pypi/remote.md)
|
||||
- `rpm`: [remote](docs/rpm/remote.md), [remote-github](docs/rpm/remote-github.md)
|
||||
- `terraform`: [remote](docs/terraform/remote.md), [local](docs/terraform/local.md)
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# 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`.
|
||||
@@ -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/<name>`.
|
||||
|
||||
- `<module>/@v/list` is the union of member versions, sorted by semver.
|
||||
- `<module>/@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/<version>.{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.
|
||||
@@ -5,3 +5,6 @@ Shared classification, TTL and eviction rules: [caching](../common/caching.md).
|
||||
| Type | Mutable (auto-detected) | Immutable (auto-detected) |
|
||||
|---|---|---|
|
||||
| `puppet` | `v3/modules/*`, `v3/releases*` | `.tar.gz` |
|
||||
|
||||
`/v3/releases` forwards the `module`, `limit` and `offset` query params
|
||||
upstream; set `base_url` to the Forge API root, e.g. `https://forgeapi.puppet.com`.
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# `puppet` virtual
|
||||
|
||||
A puppet virtual serves one Forge v3 API over local and remote puppet members:
|
||||
`puppet module install` and r10k work against `/api/v1/virtual/<name>`, with
|
||||
dependencies resolved across members.
|
||||
|
||||
- `/v3/releases?module=<slug>` merges member releases; on a duplicate version
|
||||
the earlier member wins. Releases are newest first, paginated with
|
||||
`limit` (up to 100) and `offset`; pagination links and `file_uri` are
|
||||
relative to the Forge root, so downloads route via the virtual.
|
||||
- `/v3/modules/<slug>` lists the merged releases with the highest version as
|
||||
`current_release`.
|
||||
- `/v3/releases/<slug>-<version>` and `/v3/files/<file>` 302 to the member
|
||||
owning that release.
|
||||
- A member error fails the request with 502; a member without the module is
|
||||
skipped. Merges, and modules no member has, are cached for 60s.
|
||||
|
||||
```hcl
|
||||
resource "artifactapi_virtual" "puppet" {
|
||||
name = "puppet"
|
||||
package_type = "puppet"
|
||||
members = [artifactapi_local_puppet.internal.name, artifactapi_remote_puppet.forge.name]
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
puppet module install unkin-hello \
|
||||
--module_repository https://artifactapi.example.com/api/v1/virtual/puppet
|
||||
```
|
||||
|
||||
```yaml
|
||||
# r10k.yaml
|
||||
forge:
|
||||
baseurl: https://artifactapi.example.com/api/v1/virtual/puppet
|
||||
```
|
||||
|
||||
Upload to the local member directly; the virtual is read-only.
|
||||
@@ -38,7 +38,11 @@ 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); puppet over two
|
||||
locals via real `puppet module install` (dependency resolved across members)
|
||||
and `r10k`, downloads 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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,13 +17,18 @@ import (
|
||||
const puppetHelloMetadata = `{"name":"unkin-hello","version":"0.1.0","author":"unkin","summary":"e2e","license":"Apache-2.0","source":"https://example.invalid","dependencies":[{"name":"puppetlabs/stdlib","version_requirement":">= 4.0.0 < 10.0.0"}]}`
|
||||
|
||||
func puppetModuleTarball(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
return puppetTarball(t, "unkin-hello-0.1.0", puppetHelloMetadata)
|
||||
}
|
||||
|
||||
func puppetTarball(t *testing.T, dir, metadata string) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
zw := gzip.NewWriter(&buf)
|
||||
tw := tar.NewWriter(zw)
|
||||
for name, body := range map[string]string{
|
||||
"unkin-hello-0.1.0/metadata.json": puppetHelloMetadata,
|
||||
"unkin-hello-0.1.0/manifests/init.pp": "class hello {}\n",
|
||||
dir + "/metadata.json": metadata,
|
||||
dir + "/manifests/init.pp": "class stub {}\n",
|
||||
} {
|
||||
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: int64(len(body)), ModTime: time.Now()}); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
//go:build dockere2e
|
||||
|
||||
package e2edocker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestVirtualPuppetInstall puts unkin-hello and its stdlib dependency on two
|
||||
// puppet locals and installs through a virtual over both with the real puppet
|
||||
// module tool (dependencies resolved) and r10k, downloads 302ing to each owner.
|
||||
func TestVirtualPuppetInstall(t *testing.T) {
|
||||
network := os.Getenv("COMPOSE_NETWORK")
|
||||
internal := strings.TrimRight(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-puppet-a-%d", id), fmt.Sprintf("e2e-puppet-b-%d", id), fmt.Sprintf("e2e-puppet-v-%d", id)
|
||||
modules := map[string]struct{ file, dir, meta string }{
|
||||
a: {"unkin-hello-0.1.0.tar.gz", "unkin-hello-0.1.0", puppetHelloMetadata},
|
||||
b: {"puppetlabs-stdlib-9.6.0.tar.gz", "puppetlabs-stdlib-9.6.0", `{"name":"puppetlabs-stdlib","version":"9.6.0","author":"puppetlabs","summary":"stub","license":"Apache-2.0","source":"https://example.invalid","dependencies":[]}`},
|
||||
}
|
||||
for repo, m := range modules {
|
||||
createRepo(t, fmt.Sprintf(`{"name":%q,"package_type":"puppet","repo_type":"local"}`, repo))
|
||||
defer deleteRepo(t, repo)
|
||||
if resp, body := doRequest(t, http.MethodPut, api("/api/v2/remotes/"+repo+"/files/"+m.file), puppetTarball(t, m.dir, m.meta), "application/gzip"); resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("upload %s: %d %s", m.file, resp.StatusCode, body)
|
||||
}
|
||||
defer doRequest(t, http.MethodDelete, api("/api/v2/remotes/"+repo+"/files/v3/files/"+m.file), nil, "")
|
||||
if resp, body := getEventually(t, api("/api/v1/local/"+repo+"/v3/releases/"+strings.TrimSuffix(m.file, ".tar.gz")), 15*time.Second); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("%s never indexed: %d %s", m.file, resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
createVirtual(t, fmt.Sprintf(`{"name":%q,"package_type":"puppet","members":[%q,%q]}`, virt, a, b))
|
||||
defer deleteVirtual(t, virt)
|
||||
|
||||
forge := internal + "/api/v1/virtual/" + virt
|
||||
run := func(image, script string) string {
|
||||
t.Helper()
|
||||
out, err := exec.Command("docker", "run", "--rm", "--network", network, "--entrypoint", "sh", image, "-c", "set -eu; "+script).CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("%s failed: %v\n%s", image, err, out)
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
out := run("ghcr.io/openvoxproject/openvoxagent:latest", "export PATH=/opt/puppetlabs/bin:$PATH; puppet module install unkin-hello --module_repository "+forge+" --target-dir /tmp/mods && puppet module list --color=false --modulepath /tmp/mods")
|
||||
for _, want := range []string{"unkin-hello (v0.1.0)", "puppetlabs-stdlib (v9.6.0)"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("puppet module install missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
|
||||
out = run("ghcr.io/voxpupuli/r10k:latest", "cd /tmp && printf 'forge:\\n baseurl: "+forge+"\\n' > r10k.yaml && "+
|
||||
"printf \"mod 'unkin-hello', '0.1.0'\\nmod 'puppetlabs-stdlib', '9.6.0'\\n\" > Puppetfile && "+
|
||||
"r10k puppetfile install -c r10k.yaml && cat modules/hello/metadata.json modules/stdlib/metadata.json")
|
||||
if !strings.Contains(out, `"unkin-hello"`) || !strings.Contains(out, `"puppetlabs-stdlib"`) {
|
||||
t.Fatalf("r10k did not install both modules:\n%s", out)
|
||||
}
|
||||
|
||||
noRedirect := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
||||
for repo, m := range modules {
|
||||
resp, err := noRedirect.Get(api("/api/v1/virtual/" + virt + "/v3/files/" + m.file))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
if loc := resp.Header.Get("Location"); resp.StatusCode != http.StatusFound || !strings.HasSuffix(loc, "/api/v1/local/"+repo+"/v3/files/"+m.file) {
|
||||
t.Errorf("%s = %d %q, want 302 to %s", m.file, resp.StatusCode, loc, repo)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -215,6 +215,14 @@ func (h *ProxyHandler) handleVirtual(w http.ResponseWriter, r *http.Request) {
|
||||
h.serveVirtualAnsible(w, r, *virt, path, proxyBaseURL)
|
||||
return
|
||||
}
|
||||
if virt.PackageType == models.PackagePuppet {
|
||||
h.serveVirtualPuppet(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 {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/provider/puppet"
|
||||
"git.unkin.net/unkin/artifactapi/internal/virtual"
|
||||
"git.unkin.net/unkin/artifactapi/pkg/models"
|
||||
)
|
||||
|
||||
// serveVirtualPuppet serves the merged Forge v3 module and release listings
|
||||
// and 302s release details and downloads to the member owning that release.
|
||||
func (h *ProxyHandler) serveVirtualPuppet(w http.ResponseWriter, r *http.Request, virt models.Virtual, path, proxyBaseURL string) {
|
||||
var doc any
|
||||
var err error
|
||||
switch {
|
||||
case path == "v3/releases":
|
||||
q := r.URL.Query()
|
||||
slug := puppet.NormalizeSlug(q.Get("module"))
|
||||
if !puppet.ValidSlug(slug) {
|
||||
forgeError(w, http.StatusBadRequest, "module is required")
|
||||
return
|
||||
}
|
||||
var d *virtual.PuppetDoc
|
||||
d, err = h.virtualEngine.PuppetReleases(r.Context(), virt, slug)
|
||||
releases := []any{}
|
||||
if errors.Is(err, virtual.ErrNotFound) {
|
||||
err = nil
|
||||
} else if err == nil {
|
||||
for _, rel := range d.Releases {
|
||||
releases = append(releases, rel)
|
||||
}
|
||||
}
|
||||
doc = puppet.ReleasesPage(slug, releases, q)
|
||||
case strings.HasPrefix(path, "v3/modules/"):
|
||||
slug := puppet.NormalizeSlug(strings.TrimPrefix(path, "v3/modules/"))
|
||||
if !puppet.ValidSlug(slug) {
|
||||
puppet.NotFound(w)
|
||||
return
|
||||
}
|
||||
var d *virtual.PuppetDoc
|
||||
if d, err = h.virtualEngine.PuppetReleases(r.Context(), virt, slug); err == nil {
|
||||
doc, err = virtual.PuppetModule(d)
|
||||
}
|
||||
default:
|
||||
var loc string
|
||||
loc, err = h.virtualEngine.PuppetRedirect(r.Context(), virt, path, proxyBaseURL)
|
||||
if err == nil {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Vary", "Host, X-Forwarded-Proto")
|
||||
http.Redirect(w, r, loc, http.StatusFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, virtual.ErrNotFound):
|
||||
puppet.NotFound(w)
|
||||
case errors.Is(err, virtual.ErrBadPath):
|
||||
forgeError(w, http.StatusBadRequest, err.Error())
|
||||
case err != nil:
|
||||
slog.Error("virtual puppet fetch failed", "virtual", virt.Name, "path", path, "error", err)
|
||||
forgeError(w, http.StatusBadGateway, "bad gateway")
|
||||
default:
|
||||
w.Header().Set("X-Artifact-Source", "virtual")
|
||||
puppet.WriteJSON(w, doc)
|
||||
}
|
||||
}
|
||||
|
||||
func forgeError(w http.ResponseWriter, code int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"message": http.StatusText(code), "errors": []string{msg}})
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/provider"
|
||||
"git.unkin.net/unkin/artifactapi/internal/virtual"
|
||||
"git.unkin.net/unkin/artifactapi/pkg/models"
|
||||
)
|
||||
|
||||
func TestPuppetVirtualOverLocals(t *testing.T) {
|
||||
h, srv := npmTestServer(t)
|
||||
h.virtualEngine = virtual.NewEngine(h.db, nil)
|
||||
a, b := npmRepo(t, h, models.PackagePuppet), npmRepo(t, h, models.PackagePuppet)
|
||||
for _, p := range []struct{ repo, version string }{{a, "0.1.0"}, {b, "0.1.0"}, {b, "0.2.0"}} {
|
||||
file := "v3/files/unkin-hello-" + p.version + ".tar.gz"
|
||||
meta := &provider.PuppetMetadata{RepoName: p.repo, FilePath: file, ContentHash: "sha256:00", Slug: "unkin-hello", Version: p.version, MetadataJSON: `{"name":"unkin-hello","version":"` + p.version + `"}`}
|
||||
if err := h.db.InsertPuppetMetadata(context.Background(), meta); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
virt := "v" + a
|
||||
if err := h.db.CreateVirtual(context.Background(), &models.Virtual{Name: virt, PackageType: models.PackagePuppet, Members: []string{a, b}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base := srv.URL + "/api/v1/virtual/" + virt
|
||||
|
||||
var page struct {
|
||||
Pagination struct {
|
||||
Total int
|
||||
Next *string
|
||||
}
|
||||
Results []struct {
|
||||
Version string
|
||||
FileURI string `json:"file_uri"`
|
||||
}
|
||||
}
|
||||
getJSON(t, base+"/v3/releases?module=unkin/hello&limit=1", &page)
|
||||
if page.Pagination.Total != 2 || len(page.Results) != 1 || page.Results[0].Version != "0.2.0" || page.Pagination.Next == nil {
|
||||
t.Fatalf("page 1 = %+v", page)
|
||||
}
|
||||
if *page.Pagination.Next != "/v3/releases?limit=1&module=unkin-hello&offset=1" || page.Results[0].FileURI != "/v3/files/unkin-hello-0.2.0.tar.gz" {
|
||||
t.Fatalf("links not relative to the forge root: next %q file_uri %q", *page.Pagination.Next, page.Results[0].FileURI)
|
||||
}
|
||||
getJSON(t, base+*page.Pagination.Next, &page)
|
||||
if len(page.Results) != 1 || page.Results[0].Version != "0.1.0" || page.Pagination.Next != nil {
|
||||
t.Fatalf("page 2 = %+v", page)
|
||||
}
|
||||
|
||||
var mod struct {
|
||||
CurrentRelease struct{ Version string } `json:"current_release"`
|
||||
Releases []struct{ Version string }
|
||||
}
|
||||
getJSON(t, base+"/v3/modules/unkin-hello", &mod)
|
||||
if mod.CurrentRelease.Version != "0.2.0" || len(mod.Releases) != 2 {
|
||||
t.Fatalf("module = %+v", mod)
|
||||
}
|
||||
|
||||
noRedirect := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
||||
for p, want := range map[string]string{
|
||||
"/v3/files/unkin-hello-0.1.0.tar.gz": "/api/v1/local/" + a + "/v3/files/unkin-hello-0.1.0.tar.gz",
|
||||
"/v3/releases/unkin-hello-0.2.0": "/api/v1/local/" + b + "/v3/releases/unkin-hello-0.2.0",
|
||||
} {
|
||||
resp, err := noRedirect.Get(base + p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusFound || resp.Header.Get("Location") != srv.URL+want || resp.Header.Get("Cache-Control") != "no-store" {
|
||||
t.Errorf("%s = %d %q %q, want 302 to %s", p, resp.StatusCode, resp.Header.Get("Location"), resp.Header.Get("Cache-Control"), want)
|
||||
}
|
||||
}
|
||||
|
||||
getJSON(t, base+"/v3/releases?module=unkin-absent", &page)
|
||||
if page.Pagination.Total != 0 || len(page.Results) != 0 {
|
||||
t.Fatalf("absent module = %+v", page)
|
||||
}
|
||||
for p, want := range map[string]int{
|
||||
"/v3/releases": http.StatusBadRequest,
|
||||
"/v3/modules/unkin-absent": http.StatusNotFound,
|
||||
"/v3/files/unkin-hello-0.9.0.tar.gz": http.StatusNotFound,
|
||||
} {
|
||||
if code, body := npmDo(t, http.MethodGet, base+p, ""); code != want || !strings.Contains(body, `"errors"`) {
|
||||
t.Errorf("%s = %d %s, want %d", p, code, body, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package provider
|
||||
|
||||
// ContentValidator checks an upload's bytes (spooled to localFile) before it is stored.
|
||||
type ContentValidator interface {
|
||||
ValidateContent(storagePath, localFile string) error
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
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
|
||||
}
|
||||
|
||||
// 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]
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ const filesPrefix = "v3/files/"
|
||||
// tarball basename that `puppet module build` produces.
|
||||
var releaseRe = regexp.MustCompile(`^([a-z0-9]+)-([a-z][a-z0-9_]*)-(\d+\.\d+\.\d+(?:-[0-9a-z.-]+)?(?:\+[0-9a-z.-]+)?)$`)
|
||||
|
||||
func parseRelease(s string) (slug, version string, ok bool) {
|
||||
func ParseRelease(s string) (slug, version string, ok bool) {
|
||||
m := releaseRe.FindStringSubmatch(strings.ToLower(s))
|
||||
if m == nil {
|
||||
return "", "", false
|
||||
@@ -37,7 +37,12 @@ func parseRelease(s string) (slug, version string, ok bool) {
|
||||
return m[1] + "-" + m[2], m[3], true
|
||||
}
|
||||
|
||||
func normalizeSlug(s string) string {
|
||||
var slugRe = regexp.MustCompile(`^[a-z0-9]+-[a-z][a-z0-9_]*$`)
|
||||
|
||||
// ValidSlug reports whether s is a normalized <owner>-<name> module slug.
|
||||
func ValidSlug(s string) bool { return slugRe.MatchString(s) }
|
||||
|
||||
func NormalizeSlug(s string) string {
|
||||
return strings.ToLower(strings.ReplaceAll(s, "/", "-"))
|
||||
}
|
||||
|
||||
@@ -45,7 +50,7 @@ func normalizeSlug(s string) string {
|
||||
// resolve straight to the stored file.
|
||||
func (p *Provider) ValidateUpload(filePath string) (storagePath, contentType string, err error) {
|
||||
filename := strings.ToLower(path.Base(path.Clean("/" + filePath)))
|
||||
if _, _, ok := parseRelease(strings.TrimSuffix(filename, ".tar.gz")); !ok || !strings.HasSuffix(filename, ".tar.gz") {
|
||||
if _, _, ok := ParseRelease(strings.TrimSuffix(filename, ".tar.gz")); !ok || !strings.HasSuffix(filename, ".tar.gz") {
|
||||
return "", "", fmt.Errorf("file must be named <owner>-<module>-<version>.tar.gz")
|
||||
}
|
||||
return filesPrefix + filename, "application/gzip", nil
|
||||
@@ -82,7 +87,7 @@ func (p *Provider) AfterUpload(ctx context.Context, repoName, storagePath, conte
|
||||
slog.Error("puppet metadata: read failed", "repo", repoName, "path", storagePath, "error", err)
|
||||
return
|
||||
}
|
||||
slug, version, _ := parseRelease(strings.TrimSuffix(path.Base(storagePath), ".tar.gz"))
|
||||
slug, version, _ := ParseRelease(strings.TrimSuffix(path.Base(storagePath), ".tar.gz"))
|
||||
if meta.Slug != slug || meta.Version != version {
|
||||
slog.Error("puppet metadata: metadata.json does not match filename", "repo", repoName, "path", storagePath, "name", meta.Slug, "version", meta.Version)
|
||||
return
|
||||
@@ -158,7 +163,7 @@ func parseModule(r io.Reader) (*provider.PuppetMetadata, error) {
|
||||
if md.Name == "" || md.Version == "" {
|
||||
return nil, errors.New("metadata.json missing name or version")
|
||||
}
|
||||
return &provider.PuppetMetadata{Slug: normalizeSlug(md.Name), Version: md.Version, MetadataJSON: string(body)}, nil
|
||||
return &provider.PuppetMetadata{Slug: NormalizeSlug(md.Name), Version: md.Version, MetadataJSON: string(body)}, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,9 +186,9 @@ func (p *Provider) ServeLocalIndex(w http.ResponseWriter, r *http.Request, files
|
||||
case clean == "v3/releases":
|
||||
serveReleaseList(w, r, list)
|
||||
case strings.HasPrefix(clean, "v3/releases/"):
|
||||
slug, version, ok := parseRelease(strings.TrimPrefix(clean, "v3/releases/"))
|
||||
slug, version, ok := ParseRelease(strings.TrimPrefix(clean, "v3/releases/"))
|
||||
if !ok {
|
||||
notFound(w)
|
||||
NotFound(w)
|
||||
return true
|
||||
}
|
||||
metas, err := list(slug)
|
||||
@@ -193,23 +198,23 @@ func (p *Provider) ServeLocalIndex(w http.ResponseWriter, r *http.Request, files
|
||||
}
|
||||
for _, m := range metas {
|
||||
if m.Version == version {
|
||||
writeJSON(w, releaseObject(m))
|
||||
WriteJSON(w, releaseObject(m))
|
||||
return true
|
||||
}
|
||||
}
|
||||
notFound(w)
|
||||
NotFound(w)
|
||||
case strings.HasPrefix(clean, "v3/modules/"):
|
||||
slug := normalizeSlug(strings.TrimPrefix(clean, "v3/modules/"))
|
||||
slug := NormalizeSlug(strings.TrimPrefix(clean, "v3/modules/"))
|
||||
metas, err := list(slug)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return true
|
||||
}
|
||||
if len(metas) == 0 {
|
||||
notFound(w)
|
||||
NotFound(w)
|
||||
return true
|
||||
}
|
||||
writeJSON(w, moduleObject(metas))
|
||||
WriteJSON(w, moduleObject(metas))
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@@ -222,7 +227,22 @@ func (p *Provider) GenerateLocalIndex(ctx context.Context, files provider.FileSt
|
||||
|
||||
func serveReleaseList(w http.ResponseWriter, r *http.Request, list func(string) ([]provider.PuppetMetadata, error)) {
|
||||
q := r.URL.Query()
|
||||
slug := normalizeSlug(q.Get("module"))
|
||||
slug := NormalizeSlug(q.Get("module"))
|
||||
metas, err := list(slug)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
releases := make([]any, len(metas))
|
||||
for i, m := range metas {
|
||||
releases[i] = releaseObject(m)
|
||||
}
|
||||
WriteJSON(w, ReleasesPage(slug, releases, q))
|
||||
}
|
||||
|
||||
// ReleasesPage is one limit/offset page of a /v3/releases listing, with
|
||||
// pagination links relative to the Forge root.
|
||||
func ReleasesPage(slug string, releases []any, q url.Values) map[string]any {
|
||||
limit, err := strconv.Atoi(q.Get("limit"))
|
||||
if err != nil || limit <= 0 {
|
||||
limit = 20
|
||||
@@ -232,20 +252,10 @@ func serveReleaseList(w http.ResponseWriter, r *http.Request, list func(string)
|
||||
if err != nil || offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
metas, err := list(slug)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
offset = min(offset, len(metas))
|
||||
results := []map[string]any{}
|
||||
for _, m := range metas[offset : offset+min(limit, len(metas)-offset)] {
|
||||
results = append(results, releaseObject(m))
|
||||
}
|
||||
offset = min(offset, len(releases))
|
||||
|
||||
page := func(off int) any {
|
||||
if off < 0 || off >= len(metas) && off != 0 {
|
||||
if off < 0 || off >= len(releases) && off != 0 {
|
||||
return nil
|
||||
}
|
||||
v := url.Values{"limit": {strconv.Itoa(limit)}, "offset": {strconv.Itoa(off)}}
|
||||
@@ -258,7 +268,7 @@ func serveReleaseList(w http.ResponseWriter, r *http.Request, list func(string)
|
||||
if offset > 0 {
|
||||
previous = page(max(offset-limit, 0))
|
||||
}
|
||||
writeJSON(w, map[string]any{
|
||||
return map[string]any{
|
||||
"pagination": map[string]any{
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
@@ -266,10 +276,10 @@ func serveReleaseList(w http.ResponseWriter, r *http.Request, list func(string)
|
||||
"previous": previous,
|
||||
"current": page(offset),
|
||||
"next": page(offset + limit),
|
||||
"total": len(metas),
|
||||
"total": len(releases),
|
||||
},
|
||||
"results": results,
|
||||
})
|
||||
"results": releases[offset : offset+min(limit, len(releases)-offset)],
|
||||
}
|
||||
}
|
||||
|
||||
func owner(slug string) map[string]any {
|
||||
@@ -329,7 +339,7 @@ func moduleObject(metas []provider.PuppetMetadata) map[string]any {
|
||||
return obj
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v any) {
|
||||
func WriteJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
enc := json.NewEncoder(w)
|
||||
@@ -337,7 +347,7 @@ func writeJSON(w http.ResponseWriter, v any) {
|
||||
_ = enc.Encode(v)
|
||||
}
|
||||
|
||||
func notFound(w http.ResponseWriter) {
|
||||
func NotFound(w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = io.WriteString(w, `{"message":"404 Not Found","errors":["The requested resource could not be found"]}`)
|
||||
@@ -349,11 +359,11 @@ func sortReleases(metas []provider.PuppetMetadata) {
|
||||
if c := strings.Compare(a.Slug, b.Slug); c != 0 {
|
||||
return c
|
||||
}
|
||||
return compareSemver(b.Version, a.Version)
|
||||
return CompareSemver(b.Version, a.Version)
|
||||
})
|
||||
}
|
||||
|
||||
func compareSemver(a, b string) int {
|
||||
func CompareSemver(a, b string) int {
|
||||
a, _, _ = strings.Cut(a, "+")
|
||||
b, _, _ = strings.Cut(b, "+")
|
||||
aCore, aPre, aHasPre := strings.Cut(a, "-")
|
||||
|
||||
@@ -291,11 +291,11 @@ func TestFilesFallThrough(t *testing.T) {
|
||||
func TestCompareSemver(t *testing.T) {
|
||||
ordered := []string{"0.1.0", "0.2.0", "0.10.0", "1.0.0-alpha", "1.0.0-alpha.1", "1.0.0-beta", "1.0.0-beta.2", "1.0.0-beta.11", "1.0.0", "1.0.1+build"}
|
||||
for i := 1; i < len(ordered); i++ {
|
||||
if compareSemver(ordered[i-1], ordered[i]) >= 0 || compareSemver(ordered[i], ordered[i-1]) <= 0 {
|
||||
if CompareSemver(ordered[i-1], ordered[i]) >= 0 || CompareSemver(ordered[i], ordered[i-1]) <= 0 {
|
||||
t.Errorf("%s should sort before %s", ordered[i-1], ordered[i])
|
||||
}
|
||||
}
|
||||
if compareSemver("1.0.0+a", "1.0.0+b") != 0 {
|
||||
if CompareSemver("1.0.0+a", "1.0.0+b") != 0 {
|
||||
t.Error("build metadata must not affect precedence")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package puppet
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/auth"
|
||||
@@ -39,6 +40,32 @@ func (p *Provider) UpstreamURL(remote models.Remote, path string) string {
|
||||
return strings.TrimRight(remote.BaseURL, "/") + "/" + strings.TrimLeft(path, "/")
|
||||
}
|
||||
|
||||
// ForwardedQuery keeps the module filter and pagination on release listings.
|
||||
func (p *Provider) ForwardedQuery(path string) map[string]provider.QueryValidator {
|
||||
if path != "v3/releases" {
|
||||
return nil
|
||||
}
|
||||
return map[string]provider.QueryValidator{"module": moduleParam, "limit": pageLimit, "offset": pageOffset}
|
||||
}
|
||||
|
||||
func moduleParam(v string) (string, bool) {
|
||||
v = NormalizeSlug(v)
|
||||
return v, ValidSlug(v)
|
||||
}
|
||||
|
||||
func pageLimit(v string) (string, bool) {
|
||||
n, err := strconv.ParseUint(v, 10, 32)
|
||||
if err != nil || n == 0 {
|
||||
return "", false
|
||||
}
|
||||
return strconv.FormatUint(min(n, 100), 10), true
|
||||
}
|
||||
|
||||
func pageOffset(v string) (string, bool) {
|
||||
n, err := strconv.ParseUint(v, 10, 32)
|
||||
return strconv.FormatUint(n, 10), err == nil
|
||||
}
|
||||
|
||||
func (p *Provider) RewritesResponse() {}
|
||||
|
||||
func (p *Provider) RewriteResponse(body []byte, remote models.Remote, proxyBaseURL string) ([]byte, error) {
|
||||
|
||||
@@ -76,3 +76,28 @@ func TestAuthHeaders(t *testing.T) {
|
||||
t.Error("no credentials, no header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardedQuery(t *testing.T) {
|
||||
p := &Provider{}
|
||||
if p.ForwardedQuery("v3/files/a-b-1.0.0.tar.gz") != nil || p.ForwardedQuery("v3/modules/a-b") != nil {
|
||||
t.Fatal("only release listings forward a query")
|
||||
}
|
||||
f := p.ForwardedQuery("v3/releases")
|
||||
for name, c := range map[string]struct {
|
||||
in, want string
|
||||
ok bool
|
||||
}{
|
||||
"module": {"Puppetlabs/stdlib", "puppetlabs-stdlib", true},
|
||||
"limit": {"500", "100", true},
|
||||
"offset": {"40", "40", true},
|
||||
} {
|
||||
if got, ok := f[name](c.in); got != c.want || ok != c.ok {
|
||||
t.Errorf("%s(%q) = %q %v, want %q %v", name, c.in, got, ok, c.want, c.ok)
|
||||
}
|
||||
}
|
||||
for name, bad := range map[string]string{"module": "../x", "limit": "0", "offset": "-1"} {
|
||||
if _, ok := f[name](bad); ok {
|
||||
t.Errorf("%s(%q) accepted", name, bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,10 @@ 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)
|
||||
|
||||
puppetDoc func(ctx context.Context, remote models.Remote, path string) ([]byte, error)
|
||||
|
||||
mergeTTL time.Duration
|
||||
|
||||
sf singleflight.Group
|
||||
@@ -74,6 +78,8 @@ 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
|
||||
e.puppetDoc = e.fetchMemberPath
|
||||
return e
|
||||
}
|
||||
|
||||
@@ -416,8 +422,9 @@ func (e *Engine) fetchMemberPath(ctx context.Context, remote models.Remote, path
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("provider %q does not serve a local index", remote.PackageType)
|
||||
}
|
||||
reqPath, _, _ := strings.Cut(path, "?")
|
||||
serve = func(w http.ResponseWriter, r *http.Request) bool {
|
||||
return indexer.ServeLocalIndex(w, r, e.db, remote.Name, path)
|
||||
return indexer.ServeLocalIndex(w, r, e.db, remote.Name, reqPath)
|
||||
}
|
||||
} else if rs, ok := prov.(provider.RemoteServer); ok {
|
||||
serve = func(w http.ResponseWriter, r *http.Request) bool {
|
||||
@@ -428,6 +435,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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
package virtual
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
"slices"
|
||||
"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; missing caches that no member has it.
|
||||
type GoDoc struct {
|
||||
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) 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 <module>/@v/list body, or a
|
||||
// root-relative redirect for @latest and @v/<version>.{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
|
||||
}
|
||||
d, err := MergeGoList(members)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return &GoDoc{missing: true}, nil
|
||||
}
|
||||
return d, err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if inserted {
|
||||
e.evictOldest(prefix, maxGoModules)
|
||||
}
|
||||
if d := repo.(*GoDoc); !d.missing {
|
||||
return d, nil
|
||||
}
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package virtual
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/database"
|
||||
"git.unkin.net/unkin/artifactapi/internal/testsupport"
|
||||
"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)
|
||||
}
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package virtual
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/provider/puppet"
|
||||
"git.unkin.net/unkin/artifactapi/internal/proxy"
|
||||
"git.unkin.net/unkin/artifactapi/pkg/models"
|
||||
)
|
||||
|
||||
const (
|
||||
maxPuppetModules = 1024
|
||||
puppetMemberPage = 100
|
||||
maxPuppetMemberPages = 100
|
||||
)
|
||||
|
||||
// PuppetMember is one member's releases of a module; Found is false when the
|
||||
// member does not have it.
|
||||
type PuppetMember struct {
|
||||
RemoteName string
|
||||
Found bool
|
||||
Releases []map[string]any
|
||||
}
|
||||
|
||||
// PuppetDoc is a module's merged releases, newest version first, with
|
||||
// file_uri routed via the virtual; Owners maps each version to its member;
|
||||
// missing caches that no member has the module.
|
||||
type PuppetDoc struct {
|
||||
Releases []map[string]any
|
||||
Owners map[string]string
|
||||
body []byte
|
||||
missing bool
|
||||
}
|
||||
|
||||
func (d *PuppetDoc) index() []byte { return d.body }
|
||||
func (d *PuppetDoc) files() map[string][]byte { return nil }
|
||||
|
||||
// MergePuppetReleases merges member releases of slug; the earlier member wins
|
||||
// a duplicate version. A release whose file is not <slug>-<version>.tar.gz is
|
||||
// dropped, since its download could not be routed. ErrNotFound means no
|
||||
// member has the module.
|
||||
func MergePuppetReleases(slug string, members []PuppetMember) (*PuppetDoc, error) {
|
||||
d := &PuppetDoc{Releases: []map[string]any{}, Owners: map[string]string{}}
|
||||
found := false
|
||||
for _, m := range members {
|
||||
found = found || m.Found
|
||||
for _, r := range m.Releases {
|
||||
v, _ := r["version"].(string)
|
||||
file, _ := r["file_uri"].(string)
|
||||
name := path.Base(file)
|
||||
s, fv, ok := puppet.ParseRelease(strings.TrimSuffix(name, ".tar.gz"))
|
||||
if !ok || s != slug || fv != v || !strings.HasSuffix(name, ".tar.gz") {
|
||||
continue
|
||||
}
|
||||
if _, dup := d.Owners[v]; dup {
|
||||
continue
|
||||
}
|
||||
d.Owners[v] = m.RemoteName
|
||||
r["file_uri"] = "/v3/files/" + name
|
||||
d.Releases = append(d.Releases, r)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
slices.SortStableFunc(d.Releases, func(a, b map[string]any) int {
|
||||
return puppet.CompareSemver(b["version"].(string), a["version"].(string))
|
||||
})
|
||||
var err error
|
||||
d.body, err = json.Marshal(d.Releases)
|
||||
return d, err
|
||||
}
|
||||
|
||||
// PuppetModule is the /v3/modules/<slug> document for d: the newest release
|
||||
// as current_release and every release listed.
|
||||
func PuppetModule(d *PuppetDoc) (map[string]any, error) {
|
||||
if len(d.Releases) == 0 {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
top := d.Releases[0]
|
||||
ref, ok := top["module"].(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("release %v has no module", top["slug"])
|
||||
}
|
||||
mod := maps.Clone(ref)
|
||||
releases := make([]map[string]any, len(d.Releases))
|
||||
for i, r := range d.Releases {
|
||||
releases[i] = map[string]any{}
|
||||
for _, k := range []string{"uri", "slug", "version", "file_uri", "file_size", "supported", "created_at", "deleted_at"} {
|
||||
releases[i][k] = r[k]
|
||||
}
|
||||
}
|
||||
mod["current_release"] = top
|
||||
mod["releases"] = releases
|
||||
return mod, nil
|
||||
}
|
||||
|
||||
// PuppetReleases returns the merged releases of slug across members.
|
||||
func (e *Engine) PuppetReleases(ctx context.Context, virt models.Virtual, slug string) (*PuppetDoc, error) {
|
||||
prefix := "puppet\x00" + virt.Name + "\x00"
|
||||
inserted := false
|
||||
repo, err := e.merged(ctx, prefix+slug, func(ctx context.Context) (mergedRepo, error) {
|
||||
inserted = true
|
||||
members, err := fetchMembers(virt, func(m string) (*PuppetMember, error) { return e.puppetMember(ctx, m, slug) })
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d, err := MergePuppetReleases(slug, members)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return &PuppetDoc{missing: true}, nil
|
||||
}
|
||||
return d, err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if inserted {
|
||||
e.evictOldest(prefix, maxPuppetModules)
|
||||
}
|
||||
if d := repo.(*PuppetDoc); !d.missing {
|
||||
return d, nil
|
||||
}
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
// PuppetRedirect maps v3/releases/<slug>-<version> or v3/files/<file> to the
|
||||
// route of the member owning that release.
|
||||
func (e *Engine) PuppetRedirect(ctx context.Context, virt models.Virtual, p, proxyBaseURL string) (string, error) {
|
||||
rel, ok := strings.CutPrefix(p, "v3/releases/")
|
||||
if !ok {
|
||||
file, isFile := strings.CutPrefix(p, "v3/files/")
|
||||
if rel, ok = strings.CutSuffix(file, ".tar.gz"); !isFile || !ok {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
}
|
||||
slug, version, ok := puppet.ParseRelease(rel)
|
||||
if !ok {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
d, err := e.PuppetReleases(ctx, virt, slug)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
owner, ok := d.Owners[version]
|
||||
if !ok {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
loc, ok, err := e.memberLocation(ctx, owner, p, "", proxyBaseURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !ok {
|
||||
return "", fmt.Errorf("member %q not found", owner)
|
||||
}
|
||||
return loc, nil
|
||||
}
|
||||
|
||||
// puppetMember reads every release a member lists for slug, following its
|
||||
// pagination.
|
||||
func (e *Engine) puppetMember(ctx context.Context, member, slug string) (*PuppetMember, error) {
|
||||
remote, err := e.getRemote(ctx, member)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("remote %q: %w", member, err)
|
||||
}
|
||||
m := &PuppetMember{RemoteName: member}
|
||||
q := url.Values{"module": {slug}, "limit": {strconv.Itoa(puppetMemberPage)}, "exclude_fields": {"readme changelog license reference"}}
|
||||
for page := 0; page < maxPuppetMemberPages; page++ {
|
||||
if page > 0 {
|
||||
q.Set("offset", strconv.Itoa(len(m.Releases)))
|
||||
}
|
||||
p := "v3/releases?" + q.Encode()
|
||||
body, err := e.puppetDoc(ctx, *remote, p)
|
||||
var pe *proxy.ProxyError
|
||||
if errors.Is(err, ErrNotFound) || errors.As(err, &pe) && pe.Status == http.StatusNotFound {
|
||||
return m, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var doc struct {
|
||||
Pagination struct{ Total int }
|
||||
Results []map[string]any
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(body))
|
||||
dec.UseNumber()
|
||||
if err := dec.Decode(&doc); err != nil {
|
||||
return nil, fmt.Errorf("%s/%s: %v", member, p, err)
|
||||
}
|
||||
m.Found = m.Found || doc.Pagination.Total > 0
|
||||
m.Releases = append(m.Releases, doc.Results...)
|
||||
if len(m.Releases) >= doc.Pagination.Total {
|
||||
return m, nil
|
||||
}
|
||||
if len(doc.Results) == 0 {
|
||||
return nil, fmt.Errorf("%s/%s: empty page after %d of %d releases", member, p, len(m.Releases), doc.Pagination.Total)
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("%s: more than %d pages of %s releases", member, maxPuppetMemberPages, slug)
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package virtual
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/proxy"
|
||||
"git.unkin.net/unkin/artifactapi/pkg/models"
|
||||
)
|
||||
|
||||
func puppetRel(slug, version string) map[string]any {
|
||||
return map[string]any{
|
||||
"slug": slug + "-" + version, "version": version,
|
||||
"file_uri": "/v3/files/" + slug + "-" + version + ".tar.gz",
|
||||
"module": map[string]any{"slug": slug, "uri": "/v3/modules/" + slug},
|
||||
}
|
||||
}
|
||||
|
||||
func versionsOf(rs []map[string]any) string {
|
||||
var out []string
|
||||
for _, r := range rs {
|
||||
out = append(out, r["version"].(string))
|
||||
}
|
||||
return strings.Join(out, ",")
|
||||
}
|
||||
|
||||
func TestMergePuppetReleases(t *testing.T) {
|
||||
absolute := puppetRel("unkin-hello", "0.3.0")
|
||||
absolute["file_uri"] = "https://forge.example/api/v1/remote/forge/v3/files/unkin-hello-0.3.0.tar.gz"
|
||||
internalDup := puppetRel("unkin-hello", "0.2.0")
|
||||
internalDup["tag"] = "internal"
|
||||
wrongFile := puppetRel("unkin-hello", "9.0.0")
|
||||
wrongFile["file_uri"] = "/v3/files/unkin-other-9.0.0.tar.gz"
|
||||
|
||||
d, err := MergePuppetReleases("unkin-hello", []PuppetMember{
|
||||
{RemoteName: "internal", Found: true, Releases: []map[string]any{puppetRel("unkin-hello", "0.1.0"), internalDup}},
|
||||
{RemoteName: "missing"},
|
||||
{RemoteName: "forge", Found: true, Releases: []map[string]any{absolute, puppetRel("unkin-hello", "0.2.0"), puppetRel("unkin-hello", "0.10.0"), wrongFile, puppetRel("unkin-other", "1.0.0")}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := versionsOf(d.Releases); got != "0.10.0,0.3.0,0.2.0,0.1.0" {
|
||||
t.Fatalf("merged = %s, want newest semver first without foreign or misnamed releases", got)
|
||||
}
|
||||
for v, owner := range map[string]string{"0.1.0": "internal", "0.2.0": "internal", "0.3.0": "forge", "0.10.0": "forge"} {
|
||||
if d.Owners[v] != owner {
|
||||
t.Errorf("owner of %s = %q, want %q", v, d.Owners[v], owner)
|
||||
}
|
||||
}
|
||||
if d.Releases[2]["tag"] != "internal" {
|
||||
t.Errorf("duplicate 0.2.0 not taken from the first member: %v", d.Releases[2])
|
||||
}
|
||||
if d.Releases[1]["file_uri"] != "/v3/files/unkin-hello-0.3.0.tar.gz" {
|
||||
t.Errorf("file_uri = %v, want routed via the virtual", d.Releases[1]["file_uri"])
|
||||
}
|
||||
|
||||
mod, err := PuppetModule(d)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mod["slug"] != "unkin-hello" || mod["current_release"].(map[string]any)["version"] != "0.10.0" || len(mod["releases"].([]map[string]any)) != 4 {
|
||||
t.Fatalf("module = %v", mod)
|
||||
}
|
||||
|
||||
if _, err := MergePuppetReleases("unkin-hello", []PuppetMember{{RemoteName: "a"}, {RemoteName: "b"}}); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("no member has it: want ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// forgePages answers a member's /v3/releases like the Forge, two per page.
|
||||
func forgePages(slug string, versions []string, p string) ([]byte, error) {
|
||||
_, raw, _ := strings.Cut(p, "?")
|
||||
q, _ := url.ParseQuery(raw)
|
||||
offset, _ := strconv.Atoi(q.Get("offset"))
|
||||
end := min(offset+2, len(versions))
|
||||
var results []map[string]any
|
||||
for _, v := range versions[min(offset, end):end] {
|
||||
results = append(results, puppetRel(slug, v))
|
||||
}
|
||||
return json.Marshal(map[string]any{"pagination": map[string]int{"total": len(versions)}, "results": results})
|
||||
}
|
||||
|
||||
func puppetEngine(doc func(remote models.Remote, path string) ([]byte, error)) *Engine {
|
||||
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
|
||||
},
|
||||
puppetDoc: func(_ context.Context, remote models.Remote, path string) ([]byte, error) { return doc(remote, path) },
|
||||
}
|
||||
}
|
||||
|
||||
func TestPuppetReleasesFollowsMemberPages(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
var asked []string
|
||||
e := puppetEngine(func(remote models.Remote, p string) ([]byte, error) {
|
||||
mu.Lock()
|
||||
asked = append(asked, remote.Name+" "+p)
|
||||
mu.Unlock()
|
||||
switch remote.Name {
|
||||
case "internal":
|
||||
return forgePages("puppetlabs-stdlib", []string{"9.6.0"}, p)
|
||||
case "gone":
|
||||
return nil, &proxy.ProxyError{Status: 404}
|
||||
}
|
||||
return forgePages("puppetlabs-stdlib", []string{"9.6.0", "9.5.0", "9.4.0", "8.0.0", "7.0.0"}, p)
|
||||
})
|
||||
virt := models.Virtual{Name: "puppet", PackageType: models.PackagePuppet, Members: []string{"internal", "gone", "forge"}}
|
||||
d, err := e.PuppetReleases(context.Background(), virt, "puppetlabs-stdlib")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if versionsOf(d.Releases) != "9.6.0,9.5.0,9.4.0,8.0.0,7.0.0" || d.Owners["9.6.0"] != "internal" || d.Owners["7.0.0"] != "forge" {
|
||||
t.Fatalf("merged = %s %v", versionsOf(d.Releases), d.Owners)
|
||||
}
|
||||
if len(asked) != 5 {
|
||||
t.Fatalf("want 1 internal + 1 gone + 3 forge pages, asked %v", asked)
|
||||
}
|
||||
for _, a := range asked {
|
||||
if strings.HasPrefix(a, "forge ") && !strings.Contains(a, "module=puppetlabs-stdlib") {
|
||||
t.Errorf("member page without module filter: %q", a)
|
||||
}
|
||||
}
|
||||
if _, err := e.PuppetReleases(context.Background(), virt, "puppetlabs-stdlib"); err != nil || len(asked) != 5 {
|
||||
t.Fatalf("merge not cached: %v, %d fetches", err, len(asked))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPuppetReleasesFailsClosed(t *testing.T) {
|
||||
for name, doc := range map[string]func(models.Remote, string) ([]byte, error){
|
||||
"member error": func(r models.Remote, p string) ([]byte, error) {
|
||||
if r.Name == "forge" {
|
||||
return nil, &proxy.ProxyError{Status: 503}
|
||||
}
|
||||
return forgePages("unkin-hello", []string{"0.1.0"}, p)
|
||||
},
|
||||
"short member list": func(models.Remote, string) ([]byte, error) {
|
||||
return []byte(`{"pagination":{"total":3},"results":[]}`), nil
|
||||
},
|
||||
} {
|
||||
e := puppetEngine(doc)
|
||||
virt := models.Virtual{Name: "puppet", PackageType: models.PackagePuppet, Members: []string{"internal", "forge"}}
|
||||
if _, err := e.PuppetReleases(context.Background(), virt, "unkin-hello"); err == nil || errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("%s: want a non-404 error, got %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPuppetRedirect(t *testing.T) {
|
||||
e := puppetEngine(func(r models.Remote, p string) ([]byte, error) {
|
||||
if r.Name == "internal" {
|
||||
return forgePages("unkin-hello", []string{"0.1.0"}, p)
|
||||
}
|
||||
return forgePages("unkin-hello", []string{"0.2.0", "0.1.0"}, p)
|
||||
})
|
||||
virt := models.Virtual{Name: "puppet", PackageType: models.PackagePuppet, Members: []string{"internal", "forge"}}
|
||||
for p, want := range map[string]string{
|
||||
"v3/files/unkin-hello-0.1.0.tar.gz": "https://a.example/api/v1/local/internal/v3/files/unkin-hello-0.1.0.tar.gz",
|
||||
"v3/releases/unkin-hello-0.2.0": "https://a.example/api/v1/remote/forge/v3/releases/unkin-hello-0.2.0",
|
||||
} {
|
||||
if loc, err := e.PuppetRedirect(context.Background(), virt, p, "https://a.example"); err != nil || loc != want {
|
||||
t.Errorf("%s -> %q %v, want %q", p, loc, err, want)
|
||||
}
|
||||
}
|
||||
for _, p := range []string{"v3/files/unkin-hello-0.3.0.tar.gz", "v3/files/unkin-hello-0.1.0.zip", "v3/releases/nope", "v3/users/unkin"} {
|
||||
if _, err := e.PuppetRedirect(context.Background(), virt, p, "https://a.example"); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("%s: want ErrNotFound, got %v", p, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPuppetReleasesCachesMissingModule(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
asked := map[string]int{}
|
||||
e := puppetEngine(func(r models.Remote, p string) ([]byte, error) {
|
||||
mu.Lock()
|
||||
asked[r.Name]++
|
||||
mu.Unlock()
|
||||
if r.Name == "internal" {
|
||||
return nil, &proxy.ProxyError{Status: 404}
|
||||
}
|
||||
return forgePages("unkin-nope", nil, p)
|
||||
})
|
||||
virt := models.Virtual{Name: "puppet", PackageType: models.PackagePuppet, Members: []string{"internal", "forge"}}
|
||||
for range 2 {
|
||||
if _, err := e.PuppetReleases(context.Background(), virt, "unkin-nope"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("want ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
if asked["internal"] != 1 || asked["forge"] != 1 {
|
||||
t.Fatalf("a module no member has must be cached, asked %v", asked)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,31 @@ import type { PackageTypeUI } from '../types';
|
||||
|
||||
const def: PackageTypeUI = {
|
||||
color: 'green',
|
||||
snippets: ({ proxy }) => {
|
||||
snippets: ({ name, url, proxy, isLocal }) => {
|
||||
if (isLocal) {
|
||||
return [
|
||||
{
|
||||
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.',
|
||||
},
|
||||
];
|
||||
}
|
||||
return [
|
||||
{
|
||||
title: 'Point the Go module proxy here',
|
||||
|
||||
Reference in New Issue
Block a user