Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 841b9f1547 | |||
| ed6216d78b | |||
| eca686b7bb | |||
| ceae176089 | |||
| 78d24f7752 |
@@ -262,42 +262,6 @@ there's nothing to provision. To bring your own key instead, point
|
||||
`TF_SIGNING_KEY_PASSPHRASE`), which takes precedence over the generated one.
|
||||
`TF_PROVIDER_PROTOCOLS` (default `5.0,6.0`) sets the advertised plugin protocols.
|
||||
|
||||
### Local Go module proxy
|
||||
|
||||
A local `goproxy` repo serves private modules over the GOPROXY protocol
|
||||
(`@v/list`, `@latest`, `@v/<ver>.info|.mod|.zip`). Publish a version by
|
||||
uploading its files under the case-encoded module path (uppercase as `!lower`):
|
||||
|
||||
```sh
|
||||
for ext in zip mod info; do
|
||||
curl -fsSL --upload-file v0.1.0.$ext \
|
||||
https://artifactapi.k8s.syd1.au.unkin.net/api/v2/remotes/go-internal/files/example.unkin.net/hello/@v/v0.1.0.$ext
|
||||
done
|
||||
GOPROXY=https://artifactapi.k8s.syd1.au.unkin.net/api/v1/local/go-internal,https://proxy.golang.org,direct \
|
||||
GONOSUMDB=example.unkin.net go get example.unkin.net/hello@latest
|
||||
```
|
||||
|
||||
Build the zip with `golang.org/x/mod/zip` or reuse the files `go mod download`
|
||||
leaves in `$(go env GOMODCACHE)/cache/download`. Uploads are validated: zips must
|
||||
pass `zip.CheckZip` (every file under `module@version/`), `.mod` must declare the
|
||||
module, and `.info` must match the version. `.info` is optional; it is
|
||||
synthesized from the version when absent. `@v/list` lists versions with a `.mod`.
|
||||
|
||||
### Local puppet forge
|
||||
|
||||
A local `puppet` repo is a Forge: `PUT` a module tarball to
|
||||
`/api/v2/remotes/<repo>/files/<owner>-<module>-<version>.tar.gz` and the Forge
|
||||
v3 API (`/v3/modules`, `/v3/releases`, `/v3/files`) is served from each
|
||||
release's `metadata.json` at `/api/v1/local/<repo>`. Versions are immutable.
|
||||
|
||||
```bash
|
||||
puppet module build ./hello
|
||||
curl -fsSL --upload-file pkg/unkin-hello-0.1.0.tar.gz \
|
||||
https://artifactapi.k8s.syd1.au.unkin.net/api/v2/remotes/<repo>/files/unkin-hello-0.1.0.tar.gz
|
||||
puppet module install unkin-hello \
|
||||
--module_repository https://artifactapi.k8s.syd1.au.unkin.net/api/v1/local/<repo>
|
||||
```
|
||||
|
||||
### Local docker registry
|
||||
|
||||
A local `docker` repo is a real container registry, not a mirror: it serves the
|
||||
|
||||
@@ -26,8 +26,7 @@ already-running stack.
|
||||
helm, pypi, npm, rpm, alpine, puppet, terraform, goproxy, cargo, nuget) proxied through the
|
||||
mock upstream: first fetch `X-Artifact-Source: remote`, second `cache`, bytes
|
||||
verified against the origin fixture.
|
||||
- **Local uploads** — puppet (Forge v3 API via real `r10k` and `puppet module install`),
|
||||
generic (upload/download), pypi (wheel + generated `simple/`
|
||||
- **Local uploads** — generic (upload/download), pypi (wheel + generated `simple/`
|
||||
index), rpm (real package + **automatic repodata** generation).
|
||||
- **Virtual repositories** — pypi simple-index merge and helm `index.yaml` merge
|
||||
across two members.
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
//go:build dockere2e
|
||||
|
||||
package e2edocker
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/mod/module"
|
||||
modzip "golang.org/x/mod/zip"
|
||||
)
|
||||
|
||||
// TestLocalGoProxy publishes two module versions and reads them back over the
|
||||
// GOPROXY protocol.
|
||||
func TestLocalGoProxy(t *testing.T) {
|
||||
createRepo(t, `{"name":"local-go","package_type":"goproxy","repo_type":"local"}`)
|
||||
defer deleteRepo(t, "local-go")
|
||||
|
||||
const mod = "example.unkin.net/!hello"
|
||||
gomod := []byte("module example.unkin.net/Hello\n\ngo 1.25\n")
|
||||
var zip010 []byte
|
||||
for _, ver := range []string{"v0.1.0", "v0.2.0"} {
|
||||
dir := t.TempDir()
|
||||
os.WriteFile(filepath.Join(dir, "go.mod"), gomod, 0o600)
|
||||
os.WriteFile(filepath.Join(dir, "hello.go"), []byte("package hello\n"), 0o600)
|
||||
var buf bytes.Buffer
|
||||
if err := modzip.CreateFromDir(&buf, module.Version{Path: "example.unkin.net/Hello", Version: ver}, dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ver == "v0.1.0" {
|
||||
zip010 = buf.Bytes()
|
||||
}
|
||||
uploadFile(t, "local-go", mod+"/@v/"+ver+".zip", buf.Bytes(), "application/zip")
|
||||
uploadFile(t, "local-go", mod+"/@v/"+ver+".mod", gomod, "text/plain")
|
||||
}
|
||||
|
||||
resp, body := doRequest(t, http.MethodPut, api("/api/v2/remotes/local-go/files/"+mod+"/@v/v0.3.0.zip"), []byte("junk"), "application/zip")
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("invalid zip: status %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
for path, want := range map[string]string{
|
||||
"/@v/list": "v0.1.0\nv0.2.0\n",
|
||||
"/@latest": `{"Version":"v0.2.0"}`,
|
||||
"/@v/v0.1.0.info": `{"Version":"v0.1.0"}`,
|
||||
"/@v/v0.1.0.mod": string(gomod),
|
||||
"/@v/v0.1.0.zip": string(zip010),
|
||||
} {
|
||||
resp, body := doRequest(t, http.MethodGet, api("/api/v1/local/local-go/"+mod+path), nil, "")
|
||||
if resp.StatusCode != http.StatusOK || string(body) != want {
|
||||
t.Errorf("%s: status %d body %q", path, resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
//go:build dockere2e
|
||||
|
||||
package e2edocker
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
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()
|
||||
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",
|
||||
} {
|
||||
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: int64(len(body)), ModTime: time.Now()}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = tw.Write([]byte(body))
|
||||
}
|
||||
_ = tw.Close()
|
||||
_ = zw.Close()
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// TestLocalPuppetRealClient publishes a module to a puppet local and installs
|
||||
// it with the real r10k and puppet module tool. The stdlib dependency is not on
|
||||
// a local-only forge, so puppet runs with --ignore-dependencies.
|
||||
func TestLocalPuppetRealClient(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")
|
||||
}
|
||||
|
||||
createRepo(t, `{"name":"local-puppet","package_type":"puppet","repo_type":"local"}`)
|
||||
defer deleteRepo(t, "local-puppet")
|
||||
|
||||
upload := api("/api/v2/remotes/local-puppet/files/unkin-hello-0.1.0.tar.gz")
|
||||
defer doRequest(t, http.MethodDelete, api("/api/v2/remotes/local-puppet/files/v3/files/unkin-hello-0.1.0.tar.gz"), nil, "")
|
||||
tarball := puppetModuleTarball(t)
|
||||
if resp, body := doRequest(t, http.MethodPut, upload, tarball, "application/gzip"); resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("upload: %d %s", resp.StatusCode, body)
|
||||
}
|
||||
if resp, _ := doRequest(t, http.MethodPut, upload, tarball, "application/gzip"); resp.StatusCode != http.StatusConflict {
|
||||
t.Fatalf("re-upload of an existing version: %d, want 409", resp.StatusCode)
|
||||
}
|
||||
if resp, body := getEventually(t, api("/api/v1/local/local-puppet/v3/releases/unkin-hello-0.1.0"), 15*time.Second); resp.StatusCode != http.StatusOK || !strings.Contains(string(body), `"file_md5"`) {
|
||||
t.Fatalf("release never indexed: %d %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
forge := internal + "/api/v1/local/local-puppet"
|
||||
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/voxpupuli/r10k:latest", "cd /tmp && printf 'forge:\\n baseurl: "+forge+"\\n' > r10k.yaml && "+
|
||||
"printf \"forge '"+forge+"'\\nmod 'unkin-hello', '0.1.0'\\n\" > Puppetfile && "+
|
||||
"r10k puppetfile install -c r10k.yaml --verbose && cat modules/hello/metadata.json")
|
||||
if !strings.Contains(out, `"unkin-hello"`) {
|
||||
t.Fatalf("r10k did not install unkin-hello:\n%s", out)
|
||||
}
|
||||
|
||||
out = run("ghcr.io/openvoxproject/openvoxagent:latest", "export PATH=/opt/puppetlabs/bin:$PATH; puppet module install unkin-hello --module_repository "+forge+" --ignore-dependencies --target-dir /tmp/mods && puppet module list --color=false --modulepath /tmp/mods")
|
||||
if !strings.Contains(out, "unkin-hello (v0.1.0)") {
|
||||
t.Fatalf("puppet module install did not install unkin-hello:\n%s", out)
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/testsupport"
|
||||
)
|
||||
|
||||
// TestVirtualPyPIMerge uploads different packages to two pypi locals and
|
||||
@@ -136,3 +138,104 @@ func TestVirtualRPMMerge(t *testing.T) {
|
||||
t.Fatalf("real dnf against rpm virtual failed: %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVirtualDebMerge merges two local deb repos carrying the same package: the
|
||||
// first member wins the duplicate, and a real apt sees both members' packages
|
||||
// and installs through the virtual.
|
||||
func TestVirtualDebMerge(t *testing.T) {
|
||||
createRepo(t, `{"name":"vdeb-a","package_type":"deb","repo_type":"local"}`)
|
||||
createRepo(t, `{"name":"vdeb-b","package_type":"deb","repo_type":"local"}`)
|
||||
defer deleteRepo(t, "vdeb-a")
|
||||
defer deleteRepo(t, "vdeb-b")
|
||||
|
||||
uploadFile(t, "vdeb-a", "vdeb-one_1.0.0_amd64.deb", testsupport.MinimalDeb("vdeb-one", "1.0.0", "amd64"), "application/vnd.debian.binary-package")
|
||||
uploadFile(t, "vdeb-b", "vdeb-one_1.0.0_amd64.deb", testsupport.MinimalDeb("vdeb-one", "1.0.0", "amd64"), "application/vnd.debian.binary-package")
|
||||
uploadFile(t, "vdeb-b", "vdeb-two_2.0.0_amd64.deb", testsupport.MinimalDeb("vdeb-two", "2.0.0", "amd64"), "application/vnd.debian.binary-package")
|
||||
|
||||
// Local indexes regenerate asynchronously; merge only once vdeb-b lists both.
|
||||
deadline := time.Now().Add(15 * time.Second)
|
||||
for {
|
||||
resp, body := doRequest(t, http.MethodGet, api("/api/v1/local/vdeb-b/Packages"), nil, "")
|
||||
if resp.StatusCode == http.StatusOK && strings.Contains(string(body), "vdeb-two") || time.Now().After(deadline) {
|
||||
break
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
|
||||
createVirtual(t, `{"name":"vdeb","package_type":"deb","members":["vdeb-a","vdeb-b"]}`)
|
||||
defer deleteVirtual(t, "vdeb")
|
||||
|
||||
_, body := doRequest(t, http.MethodGet, api("/api/v1/virtual/vdeb/Packages"), nil, "")
|
||||
pk := string(body)
|
||||
if strings.Count(pk, "Package: vdeb-one\n") != 1 || !strings.Contains(pk, "Filename: vdeb-a/pool/vdeb-one_1.0.0_amd64.deb") ||
|
||||
!strings.Contains(pk, "Filename: vdeb-b/pool/vdeb-two_2.0.0_amd64.deb") {
|
||||
t.Fatalf("merged Packages should hold vdeb-one from the first member and vdeb-two:\n%s", pk)
|
||||
}
|
||||
|
||||
network := os.Getenv("COMPOSE_NETWORK")
|
||||
internal := os.Getenv("ARTIFACTAPI_INTERNAL")
|
||||
if network == "" || internal == "" {
|
||||
t.Skip("COMPOSE_NETWORK/ARTIFACTAPI_INTERNAL not set; skipping real apt")
|
||||
}
|
||||
if _, err := exec.LookPath("docker"); err != nil {
|
||||
t.Skip("docker not available on the test host")
|
||||
}
|
||||
script := "set -euo pipefail; " +
|
||||
"rm -f /etc/apt/sources.list.d/*; " +
|
||||
"echo \"deb [trusted=yes] $URL ./\" > /etc/apt/sources.list.d/vdeb.list; " +
|
||||
"apt-get update; " +
|
||||
"apt-cache policy vdeb-one vdeb-two | tee /dev/stderr | grep -c virtual/vdeb | grep -qx 2; " +
|
||||
"apt-get install -y vdeb-one vdeb-two; " +
|
||||
"dpkg -s vdeb-one vdeb-two"
|
||||
out, err := exec.Command("docker", "run", "--rm", "--network", network,
|
||||
"-e", "URL="+strings.TrimRight(internal, "/")+"/api/v1/virtual/vdeb/",
|
||||
"debian:13", "bash", "-c", script).CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("real apt against deb virtual failed: %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVirtualDebSuite merges two proxied deb.debian.org remotes and drives a
|
||||
// real apt through the virtual's dists/<suite> layout (by-hash indexes).
|
||||
func TestVirtualDebSuite(t *testing.T) {
|
||||
network := os.Getenv("COMPOSE_NETWORK")
|
||||
internal := os.Getenv("ARTIFACTAPI_INTERNAL")
|
||||
if network == "" || internal == "" {
|
||||
t.Skip("COMPOSE_NETWORK/ARTIFACTAPI_INTERNAL not set; skipping real apt")
|
||||
}
|
||||
if _, err := exec.LookPath("docker"); err != nil {
|
||||
t.Skip("docker not available on the test host")
|
||||
}
|
||||
for _, n := range []string{"vdebs-a", "vdebs-b"} {
|
||||
createRepo(t, `{"name":"`+n+`","package_type":"deb","repo_type":"remote","base_url":"http://deb.debian.org/debian"}`)
|
||||
defer deleteRepo(t, n)
|
||||
}
|
||||
createVirtual(t, `{"name":"vdebs","package_type":"deb","members":["vdebs-a","vdebs-b"]}`)
|
||||
defer deleteVirtual(t, "vdebs")
|
||||
|
||||
resp, rel := doRequest(t, http.MethodGet, api("/api/v1/virtual/vdebs/dists/trixie-updates/Release"), nil, "")
|
||||
if resp.StatusCode != http.StatusOK || !strings.Contains(string(rel), "Acquire-By-Hash: yes\n") ||
|
||||
!strings.Contains(string(rel), " main/binary-amd64/Packages.gz\n") {
|
||||
t.Fatalf("merged suite Release: status %d:\n%s", resp.StatusCode, rel)
|
||||
}
|
||||
|
||||
script := "set -euo pipefail; " +
|
||||
"rm -f /etc/apt/sources.list.d/*; " +
|
||||
"echo \"deb [trusted=yes] $URL trixie-updates main\" > /etc/apt/sources.list.d/vdebs.list; " +
|
||||
"apt-get update; " +
|
||||
"idx=/tmp/avail; apt-cache dumpavail > $idx; " +
|
||||
"grep -q '^Filename: vdebs-a/pool/' $idx; if grep -q '^Filename: vdebs-b/' $idx; then echo 'vdebs-b not deduped'; exit 1; fi; " +
|
||||
"pkg=$(awk '/^Package:/{p=$2} /^Size:/{if(!m||$2<m){m=$2;b=p}} END{print b}' $idx); " +
|
||||
"cd /tmp && apt-get download $pkg && ls -l /tmp/*.deb"
|
||||
out, err := exec.Command("docker", "run", "--rm", "--network", network,
|
||||
"-e", "URL="+strings.TrimRight(internal, "/")+"/api/v1/virtual/vdebs/",
|
||||
"debian:13", "bash", "-c", script).CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("real apt against deb virtual suite failed: %v\n%s", err, out)
|
||||
}
|
||||
t.Logf("%s", out)
|
||||
resp, _ = doRequest(t, http.MethodGet, api("/api/v1/remote/vdebs-a/dists/trixie-updates/main/binary-amd64/Packages.xz"), nil, "")
|
||||
if src := resp.Header.Get("X-Artifact-Source"); resp.StatusCode != http.StatusOK || src != "cache" {
|
||||
t.Fatalf("member index the virtual merged should be cached: status %d source %q", resp.StatusCode, src)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ require (
|
||||
github.com/testcontainers/testcontainers-go/modules/redis v0.42.0
|
||||
github.com/ulikunitz/xz v0.5.16
|
||||
golang.org/x/crypto v0.54.0
|
||||
golang.org/x/mod v0.37.0
|
||||
golang.org/x/sync v0.22.0
|
||||
golang.org/x/time v0.15.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
|
||||
@@ -227,8 +227,6 @@ golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
@@ -249,8 +247,6 @@ golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
@@ -84,22 +83,7 @@ func (h *LocalHandler) uploadValidated(w http.ResponseWriter, r *http.Request, r
|
||||
return
|
||||
}
|
||||
|
||||
var body io.Reader = r.Body
|
||||
if cv, ok := prov.(provider.ContentValidator); ok {
|
||||
f, err := spoolBody(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("read upload: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer discard(f)
|
||||
if err := cv.ValidateContent(storagePath, f.Name()); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
body = f
|
||||
}
|
||||
|
||||
result, err := h.cas.Store(r.Context(), body, contentType)
|
||||
result, err := h.cas.Store(r.Context(), r.Body, contentType)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("store failed: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
@@ -126,27 +110,6 @@ func (h *LocalHandler) uploadValidated(w http.ResponseWriter, r *http.Request, r
|
||||
writeJSON(w, http.StatusCreated, uploader.UploadResponse(storagePath, result.ContentHash, result.SizeBytes))
|
||||
}
|
||||
|
||||
func spoolBody(r io.Reader) (*os.File, error) {
|
||||
f, err := os.CreateTemp("", "artifactapi-upload-*")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := io.Copy(f, r); err != nil {
|
||||
discard(f)
|
||||
return nil, err
|
||||
}
|
||||
if _, err := f.Seek(0, io.SeekStart); err != nil {
|
||||
discard(f)
|
||||
return nil, err
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func discard(f *os.File) {
|
||||
_ = f.Close()
|
||||
_ = os.Remove(f.Name())
|
||||
}
|
||||
|
||||
func (h *LocalHandler) uploadGeneric(w http.ResponseWriter, r *http.Request, remote *models.Remote, filePath string) {
|
||||
existing, err := h.db.GetLocalFile(r.Context(), remote.Name, filePath)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/database"
|
||||
_ "git.unkin.net/unkin/artifactapi/internal/provider/goproxy"
|
||||
"git.unkin.net/unkin/artifactapi/pkg/models"
|
||||
)
|
||||
|
||||
// TestLocalUploadContentRejected verifies a ContentValidator provider rejects
|
||||
// bad bytes with 400 before anything is stored.
|
||||
func TestLocalUploadContentRejected(t *testing.T) {
|
||||
if testDSN == "" {
|
||||
t.Skip("Docker unavailable")
|
||||
}
|
||||
ctx := context.Background()
|
||||
db, err := database.New(testDSN)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
const repo = "go-local-validate"
|
||||
if err := db.CreateRemote(ctx, &models.Remote{Name: repo, PackageType: models.PackageGoProxy, RepoType: models.RepoTypeLocal}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Route("/remotes/{name}/files", func(r chi.Router) {
|
||||
r.Put("/*", NewLocalHandler(db, nil).Routes().ServeHTTP)
|
||||
})
|
||||
|
||||
cases := map[string]string{
|
||||
"example.unkin.net/hello/@v/v0.1.0.zip": "not a zip",
|
||||
"example.unkin.net/hello/@v/v0.1.0.mod": "module example.unkin.net/other\n",
|
||||
"example.unkin.net/hello/@v/v0.1.0.info": `{"Version":"v9.9.9"}`,
|
||||
"example.unkin.net/Hello/@v/v0.1.0.mod": "module example.unkin.net/Hello\n",
|
||||
}
|
||||
for path, body := range cases {
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, httptest.NewRequest("PUT", "/remotes/"+repo+"/files/"+path, strings.NewReader(body)))
|
||||
if w.Code != 400 {
|
||||
t.Errorf("%s: status %d, want 400 (%s)", path, w.Code, w.Body.String())
|
||||
}
|
||||
if f, _ := db.GetLocalFile(ctx, repo, path); f != nil {
|
||||
t.Errorf("%s: rejected upload was recorded", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/provider"
|
||||
)
|
||||
|
||||
func (db *DB) InsertPuppetMetadata(ctx context.Context, meta *provider.PuppetMetadata) error {
|
||||
_, err := db.Pool.Exec(ctx, `
|
||||
INSERT INTO puppet_metadata (repo_name, file_path, content_hash, slug, version, metadata_json, file_size, file_md5)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
|
||||
ON CONFLICT (repo_name, file_path) DO NOTHING
|
||||
`, meta.RepoName, meta.FilePath, meta.ContentHash, meta.Slug, meta.Version, meta.MetadataJSON, meta.Size, meta.MD5)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) DeletePuppetMetadata(ctx context.Context, repoName, filePath string) error {
|
||||
_, err := db.Pool.Exec(ctx, `DELETE FROM puppet_metadata WHERE repo_name = $1 AND file_path = $2`, repoName, filePath)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListPuppetMetadataEntries returns a repo's releases, optionally narrowed to
|
||||
// one module slug (empty slug lists every module).
|
||||
func (db *DB) ListPuppetMetadataEntries(ctx context.Context, repoName, slug string) ([]provider.PuppetMetadata, error) {
|
||||
rows, err := db.Pool.Query(ctx, `
|
||||
SELECT repo_name, file_path, content_hash, slug, version, metadata_json, file_size, file_md5, created_at
|
||||
FROM puppet_metadata
|
||||
WHERE repo_name = $1 AND ($2 = '' OR slug = $2)
|
||||
ORDER BY slug, file_path
|
||||
`, repoName, slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []provider.PuppetMetadata
|
||||
for rows.Next() {
|
||||
var m provider.PuppetMetadata
|
||||
if err := rows.Scan(&m.RepoName, &m.FilePath, &m.ContentHash, &m.Slug, &m.Version, &m.MetadataJSON, &m.Size, &m.MD5, &m.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, m)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
package goproxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/mod/modfile"
|
||||
"golang.org/x/mod/module"
|
||||
"golang.org/x/mod/semver"
|
||||
modzip "golang.org/x/mod/zip"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/provider"
|
||||
)
|
||||
|
||||
type versionFile struct {
|
||||
module string
|
||||
version string
|
||||
ext string
|
||||
}
|
||||
|
||||
// parseVersionFile splits "<escaped module>/@v/<version>.<ext>" into its parts.
|
||||
func parseVersionFile(path string) (versionFile, error) {
|
||||
escMod, file, ok := strings.Cut(strings.TrimPrefix(path, "/"), "/@v/")
|
||||
if !ok || escMod == "" || strings.Contains(file, "/") {
|
||||
return versionFile{}, fmt.Errorf("path %q must be <module>/@v/<version>.{zip,mod,info}", path)
|
||||
}
|
||||
dot := strings.LastIndex(file, ".")
|
||||
if dot <= 0 {
|
||||
return versionFile{}, fmt.Errorf("path %q must end in .zip, .mod or .info", path)
|
||||
}
|
||||
ext := file[dot+1:]
|
||||
if ext != "zip" && ext != "mod" && ext != "info" {
|
||||
return versionFile{}, fmt.Errorf("path %q must end in .zip, .mod or .info", path)
|
||||
}
|
||||
mod, err := module.UnescapePath(escMod)
|
||||
if err != nil {
|
||||
return versionFile{}, fmt.Errorf("module path must be case-encoded (uppercase as !lower): %w", err)
|
||||
}
|
||||
ver, err := module.UnescapeVersion(file[:dot])
|
||||
if err != nil {
|
||||
return versionFile{}, err
|
||||
}
|
||||
if err := module.Check(mod, ver); err != nil {
|
||||
return versionFile{}, err
|
||||
}
|
||||
if semver.Canonical(ver) != ver {
|
||||
return versionFile{}, fmt.Errorf("version %q is not canonical semver", ver)
|
||||
}
|
||||
return versionFile{module: mod, version: ver, ext: ext}, nil
|
||||
}
|
||||
|
||||
func (v versionFile) storagePath() string {
|
||||
escMod, _ := module.EscapePath(v.module)
|
||||
escVer, _ := module.EscapeVersion(v.version)
|
||||
return escMod + "/@v/" + escVer + "." + v.ext
|
||||
}
|
||||
|
||||
func (p *Provider) ValidateUpload(filePath string) (storagePath, contentType string, err error) {
|
||||
vf, err := parseVersionFile(filePath)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
sp := vf.storagePath()
|
||||
return sp, p.ContentType(sp), nil
|
||||
}
|
||||
|
||||
func (p *Provider) ValidateContent(storagePath, localFile string) error {
|
||||
vf, err := parseVersionFile(storagePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch vf.ext {
|
||||
case "zip":
|
||||
if _, err := modzip.CheckZip(module.Version{Path: vf.module, Version: vf.version}, localFile); err != nil {
|
||||
return fmt.Errorf("invalid module zip (files must sit under %s@%s/): %w", vf.module, vf.version, err)
|
||||
}
|
||||
case "mod":
|
||||
data, err := os.ReadFile(localFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mf, err := modfile.ParseLax("go.mod", data, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid go.mod: %w", err)
|
||||
}
|
||||
if mf.Module == nil || mf.Module.Mod.Path != vf.module {
|
||||
return fmt.Errorf("go.mod must declare module %s", vf.module)
|
||||
}
|
||||
case "info":
|
||||
data, err := os.ReadFile(localFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var info struct{ Version string }
|
||||
if err := json.Unmarshal(data, &info); err != nil {
|
||||
return fmt.Errorf("invalid .info JSON: %w", err)
|
||||
}
|
||||
if info.Version != vf.version {
|
||||
return fmt.Errorf(".info Version %q does not match %s", info.Version, vf.version)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Provider) UploadResponse(storagePath, contentHash string, sizeBytes int64) map[string]any {
|
||||
vf, _ := parseVersionFile(storagePath)
|
||||
return map[string]any{
|
||||
"module": vf.module,
|
||||
"version": vf.version,
|
||||
"path": storagePath,
|
||||
"content_hash": contentHash,
|
||||
"size_bytes": sizeBytes,
|
||||
}
|
||||
}
|
||||
|
||||
// versions returns the canonical versions of escMod that have a .mod uploaded, sorted ascending.
|
||||
func versions(ctx context.Context, files provider.FileStore, repoName, escMod string) (versions []string, infos map[string]bool, err error) {
|
||||
entries, err := files.ListFilesByPrefix(ctx, repoName, escMod+"/@v/")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
infos = map[string]bool{}
|
||||
for _, e := range entries {
|
||||
vf, err := parseVersionFile(e.FilePath)
|
||||
if err != nil || vf.storagePath() != e.FilePath {
|
||||
continue
|
||||
}
|
||||
switch vf.ext {
|
||||
case "mod":
|
||||
versions = append(versions, vf.version)
|
||||
case "info":
|
||||
infos[vf.version] = true
|
||||
}
|
||||
}
|
||||
semver.Sort(versions)
|
||||
return versions, infos, nil
|
||||
}
|
||||
|
||||
func latest(vs []string) string {
|
||||
for i := len(vs) - 1; i >= 0; i-- {
|
||||
if semver.Prerelease(vs[i]) == "" {
|
||||
return vs[i]
|
||||
}
|
||||
}
|
||||
if len(vs) == 0 {
|
||||
return ""
|
||||
}
|
||||
return vs[len(vs)-1]
|
||||
}
|
||||
|
||||
func infoJSON(version string) []byte {
|
||||
b, _ := json.Marshal(map[string]string{"Version": version})
|
||||
return b
|
||||
}
|
||||
|
||||
func (p *Provider) ServeLocalIndex(w http.ResponseWriter, r *http.Request, files provider.FileStore, repoName, path string) bool {
|
||||
path = strings.TrimPrefix(path, "/")
|
||||
switch {
|
||||
case strings.HasSuffix(path, "/@v/list"), strings.HasSuffix(path, "/@latest"):
|
||||
case strings.HasSuffix(path, ".info"):
|
||||
vf, err := parseVersionFile(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
escMod, _ := module.EscapePath(vf.module)
|
||||
vs, infos, err := versions(r.Context(), files, repoName, escMod)
|
||||
if err != nil || infos[vf.version] || !slices.Contains(vs, vf.version) {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
body, err := p.GenerateLocalIndex(r.Context(), files, repoName, path)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return true
|
||||
}
|
||||
w.Header().Set("Content-Type", p.ContentType(path))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(body)
|
||||
return true
|
||||
}
|
||||
|
||||
func (p *Provider) GenerateLocalIndex(ctx context.Context, files provider.FileStore, repoName, path string) ([]byte, error) {
|
||||
path = strings.TrimPrefix(path, "/")
|
||||
if escMod, ok := strings.CutSuffix(path, "/@v/list"); ok {
|
||||
vs, _, err := versions(ctx, files, repoName, escMod)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, v := range vs {
|
||||
b.WriteString(v + "\n")
|
||||
}
|
||||
return []byte(b.String()), nil
|
||||
}
|
||||
if escMod, ok := strings.CutSuffix(path, "/@latest"); ok {
|
||||
vs, _, err := versions(ctx, files, repoName, escMod)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v := latest(vs)
|
||||
if v == "" {
|
||||
return nil, fmt.Errorf("no versions of %s", escMod)
|
||||
}
|
||||
return infoJSON(v), nil
|
||||
}
|
||||
if strings.HasSuffix(path, ".info") {
|
||||
vf, err := parseVersionFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return infoJSON(vf.version), nil
|
||||
}
|
||||
return nil, fmt.Errorf("unsupported index path: %q", path)
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
package goproxy
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/mod/module"
|
||||
modzip "golang.org/x/mod/zip"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/provider"
|
||||
)
|
||||
|
||||
type fakeFileStore struct{ paths []string }
|
||||
|
||||
func (f *fakeFileStore) ListPackages(context.Context, string) ([]string, error) { return nil, nil }
|
||||
|
||||
func (f *fakeFileStore) ListFilesByPrefix(_ context.Context, _, prefix string) ([]provider.FileEntry, error) {
|
||||
var out []provider.FileEntry
|
||||
for _, p := range f.paths {
|
||||
if strings.HasPrefix(p, prefix) {
|
||||
out = append(out, provider.FileEntry{FilePath: p, ContentHash: "sha256:x"})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func TestValidateUpload(t *testing.T) {
|
||||
p := &Provider{}
|
||||
cases := []struct {
|
||||
in, want string
|
||||
ok bool
|
||||
}{
|
||||
{"example.unkin.net/hello/@v/v0.1.0.zip", "example.unkin.net/hello/@v/v0.1.0.zip", true},
|
||||
{"/example.unkin.net/hello/@v/v0.1.0.mod", "example.unkin.net/hello/@v/v0.1.0.mod", true},
|
||||
{"github.com/!burnt!sushi/toml/@v/v1.2.0.info", "github.com/!burnt!sushi/toml/@v/v1.2.0.info", true},
|
||||
{"example.unkin.net/hello/v2/@v/v2.0.0-rc.1.zip", "example.unkin.net/hello/v2/@v/v2.0.0-rc.1.zip", true},
|
||||
{"github.com/BurntSushi/toml/@v/v1.2.0.zip", "", false},
|
||||
{"example.unkin.net/hello/@v/v0.1.zip", "", false},
|
||||
{"example.unkin.net/hello/@v/v2.0.0.zip", "", false},
|
||||
{"example.unkin.net/hello/@v/v0.1.0.tar.gz", "", false},
|
||||
{"example.unkin.net/hello/v0.1.0.zip", "", false},
|
||||
{"example.unkin.net/hello/@v/list", "", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, ct, err := p.ValidateUpload(c.in)
|
||||
if (err == nil) != c.ok {
|
||||
t.Errorf("%s: err=%v, want ok=%v", c.in, err, c.ok)
|
||||
continue
|
||||
}
|
||||
if c.ok && (got != c.want || ct == "") {
|
||||
t.Errorf("%s: got %q %q, want %q", c.in, got, ct, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeZip(t *testing.T, files map[string]string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "m.zip")
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zw := zip.NewWriter(f)
|
||||
for name, body := range files {
|
||||
w, err := zw.Create(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := w.Write([]byte(body)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "f")
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestValidateContentZip(t *testing.T) {
|
||||
p := &Provider{}
|
||||
sp := "example.unkin.net/hello/@v/v0.1.0.zip"
|
||||
|
||||
dir := t.TempDir()
|
||||
os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module example.unkin.net/hello\n"), 0o600)
|
||||
os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\n"), 0o600)
|
||||
good := filepath.Join(t.TempDir(), "good.zip")
|
||||
f, _ := os.Create(good)
|
||||
if err := modzip.CreateFromDir(f, module.Version{Path: "example.unkin.net/hello", Version: "v0.1.0"}, dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.Close()
|
||||
if err := p.ValidateContent(sp, good); err != nil {
|
||||
t.Errorf("valid zip rejected: %v", err)
|
||||
}
|
||||
|
||||
bad := map[string]map[string]string{
|
||||
"no prefix": {"go.mod": "module example.unkin.net/hello\n"},
|
||||
"wrong version": {"example.unkin.net/hello@v0.2.0/go.mod": "module example.unkin.net/hello\n"},
|
||||
"wrong module": {"example.unkin.net/other@v0.1.0/go.mod": "module example.unkin.net/other\n"},
|
||||
}
|
||||
for name, files := range bad {
|
||||
if err := p.ValidateContent(sp, writeZip(t, files)); err == nil {
|
||||
t.Errorf("%s: expected rejection", name)
|
||||
}
|
||||
}
|
||||
if err := p.ValidateContent(sp, writeFile(t, "not a zip")); err == nil {
|
||||
t.Error("non-zip accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateContentModInfo(t *testing.T) {
|
||||
p := &Provider{}
|
||||
mod := "example.unkin.net/hello/@v/v0.1.0.mod"
|
||||
info := "example.unkin.net/hello/@v/v0.1.0.info"
|
||||
cases := []struct {
|
||||
path, body string
|
||||
ok bool
|
||||
}{
|
||||
{mod, "module example.unkin.net/hello\n\ngo 1.25\n", true},
|
||||
{mod, "module example.unkin.net/other\n", false},
|
||||
{mod, "go 1.25\n", false},
|
||||
{mod, "module (\n", false},
|
||||
{info, `{"Version":"v0.1.0","Time":"2026-01-01T00:00:00Z"}`, true},
|
||||
{info, `{"Version":"v0.2.0"}`, false},
|
||||
{info, `nope`, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if err := p.ValidateContent(c.path, writeFile(t, c.body)); (err == nil) != c.ok {
|
||||
t.Errorf("%s %q: err=%v, want ok=%v", c.path, c.body, err, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func serve(t *testing.T, fs provider.FileStore, path string) (bool, int, string) {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
handled := (&Provider{}).ServeLocalIndex(rec, httptest.NewRequest("GET", "/"+path, nil), fs, "go-local", path)
|
||||
return handled, rec.Code, rec.Body.String()
|
||||
}
|
||||
|
||||
func TestServeLocalIndex(t *testing.T) {
|
||||
fs := &fakeFileStore{paths: []string{
|
||||
"example.unkin.net/hello/@v/v0.10.0.mod",
|
||||
"example.unkin.net/hello/@v/v0.10.0.zip",
|
||||
"example.unkin.net/hello/@v/v0.2.0.mod",
|
||||
"example.unkin.net/hello/@v/v0.2.0.zip",
|
||||
"example.unkin.net/hello/@v/v0.2.0.info",
|
||||
"example.unkin.net/hello/@v/v0.11.0-rc.1.mod",
|
||||
"example.unkin.net/hello/@v/v0.3.0.zip",
|
||||
"example.unkin.net/hello/v2/@v/v2.0.0.mod",
|
||||
"example.unkin.net/!pre/@v/v1.0.0-beta.mod",
|
||||
}}
|
||||
|
||||
if h, code, body := serve(t, fs, "example.unkin.net/hello/@v/list"); !h || code != 200 || body != "v0.2.0\nv0.10.0\nv0.11.0-rc.1\n" {
|
||||
t.Errorf("list: %v %d %q", h, code, body)
|
||||
}
|
||||
if h, code, body := serve(t, fs, "example.unkin.net/hello/@latest"); !h || code != 200 || body != `{"Version":"v0.10.0"}` {
|
||||
t.Errorf("latest: %v %d %q", h, code, body)
|
||||
}
|
||||
if h, _, body := serve(t, fs, "example.unkin.net/!pre/@latest"); !h || body != `{"Version":"v1.0.0-beta"}` {
|
||||
t.Errorf("prerelease-only latest: %q", body)
|
||||
}
|
||||
if h, code, _ := serve(t, fs, "example.unkin.net/missing/@latest"); !h || code != 404 {
|
||||
t.Errorf("missing latest: %v %d", h, code)
|
||||
}
|
||||
if h, code, body := serve(t, fs, "example.unkin.net/missing/@v/list"); !h || code != 200 || body != "" {
|
||||
t.Errorf("missing list: %v %d %q", h, code, body)
|
||||
}
|
||||
if h, _, body := serve(t, fs, "example.unkin.net/hello/@v/v0.10.0.info"); !h || body != `{"Version":"v0.10.0"}` {
|
||||
t.Errorf("synthesized info: %v %q", h, body)
|
||||
}
|
||||
if h, _, _ := serve(t, fs, "example.unkin.net/hello/@v/v0.2.0.info"); h {
|
||||
t.Error("uploaded .info must be served from storage")
|
||||
}
|
||||
if h, _, _ := serve(t, fs, "example.unkin.net/hello/@v/v0.3.0.info"); h {
|
||||
t.Error(".info without a .mod must not be synthesized")
|
||||
}
|
||||
if h, _, _ := serve(t, fs, "example.unkin.net/hello/@v/v0.10.0.zip"); h {
|
||||
t.Error(".zip must be served from storage")
|
||||
}
|
||||
}
|
||||
@@ -41,11 +41,6 @@ type LocalUploader interface {
|
||||
UploadResponse(storagePath, contentHash string, sizeBytes int64) map[string]any
|
||||
}
|
||||
|
||||
// ContentValidator checks an upload's bytes (spooled to localFile) before it is stored.
|
||||
type ContentValidator interface {
|
||||
ValidateContent(storagePath, localFile string) error
|
||||
}
|
||||
|
||||
type LocalIndexer interface {
|
||||
ServeLocalIndex(w http.ResponseWriter, r *http.Request, files FileStore, repoName, path string) bool
|
||||
GenerateLocalIndex(ctx context.Context, files FileStore, repoName, path string) ([]byte, error)
|
||||
@@ -252,29 +247,3 @@ func Get(t models.PackageType) (Provider, error) {
|
||||
func All() map[models.PackageType]Provider {
|
||||
return registry
|
||||
}
|
||||
|
||||
type PuppetMetadataStore interface {
|
||||
InsertPuppetMetadata(ctx context.Context, meta *PuppetMetadata) error
|
||||
}
|
||||
|
||||
type PuppetMetadataDeleter interface {
|
||||
DeletePuppetMetadata(ctx context.Context, repoName, filePath string) error
|
||||
}
|
||||
|
||||
type PuppetMetadataReader interface {
|
||||
ListPuppetMetadataEntries(ctx context.Context, repoName, slug string) ([]PuppetMetadata, error)
|
||||
}
|
||||
|
||||
// PuppetMetadata is a local module release; MetadataJSON is the tarball's
|
||||
// metadata.json verbatim so Forge release objects carry every field.
|
||||
type PuppetMetadata struct {
|
||||
RepoName string
|
||||
FilePath string
|
||||
ContentHash string
|
||||
Slug string
|
||||
Version string
|
||||
MetadataJSON string
|
||||
Size int64
|
||||
MD5 string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
@@ -1,393 +0,0 @@
|
||||
package puppet
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/provider"
|
||||
"git.unkin.net/unkin/artifactapi/internal/storage"
|
||||
)
|
||||
|
||||
const filesPrefix = "v3/files/"
|
||||
|
||||
// releaseRe matches <owner>-<name>-<semver>, the Forge release slug and the
|
||||
// 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) {
|
||||
m := releaseRe.FindStringSubmatch(strings.ToLower(s))
|
||||
if m == nil {
|
||||
return "", "", false
|
||||
}
|
||||
return m[1] + "-" + m[2], m[3], true
|
||||
}
|
||||
|
||||
func normalizeSlug(s string) string {
|
||||
return strings.ToLower(strings.ReplaceAll(s, "/", "-"))
|
||||
}
|
||||
|
||||
// ValidateUpload stores tarballs under v3/files/ so Forge file_uri downloads
|
||||
// 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") {
|
||||
return "", "", fmt.Errorf("file must be named <owner>-<module>-<version>.tar.gz")
|
||||
}
|
||||
return filesPrefix + filename, "application/gzip", nil
|
||||
}
|
||||
|
||||
func (p *Provider) UploadResponse(storagePath, contentHash string, sizeBytes int64) map[string]any {
|
||||
return map[string]any{
|
||||
"filename": path.Base(storagePath),
|
||||
"content_hash": contentHash,
|
||||
"size_bytes": sizeBytes,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Provider) AfterUpload(ctx context.Context, repoName, storagePath, contentHash string, blobs provider.BlobReader, db provider.MetadataStore) {
|
||||
reader, size, err := blobs.Download(ctx, storage.BlobKey(strings.TrimPrefix(contentHash, "sha256:")))
|
||||
if err != nil {
|
||||
slog.Error("puppet metadata: download failed", "repo", repoName, "path", storagePath, "error", err)
|
||||
return
|
||||
}
|
||||
defer func() { _ = reader.Close() }()
|
||||
if size > maxModuleTarball {
|
||||
slog.Error("puppet metadata: tarball too large", "repo", repoName, "path", storagePath, "size", size)
|
||||
return
|
||||
}
|
||||
|
||||
hash := md5.New()
|
||||
src := io.TeeReader(io.LimitReader(reader, maxModuleTarball), hash)
|
||||
meta, err := parseModule(src)
|
||||
if err != nil {
|
||||
slog.Error("puppet metadata: parse failed", "repo", repoName, "path", storagePath, "error", err)
|
||||
return
|
||||
}
|
||||
if _, err := io.Copy(io.Discard, src); err != nil {
|
||||
slog.Error("puppet metadata: read failed", "repo", repoName, "path", storagePath, "error", err)
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
meta.RepoName = repoName
|
||||
meta.FilePath = storagePath
|
||||
meta.ContentHash = contentHash
|
||||
meta.Size = size
|
||||
meta.MD5 = hex.EncodeToString(hash.Sum(nil))
|
||||
|
||||
store, ok := db.(provider.PuppetMetadataStore)
|
||||
if !ok {
|
||||
slog.Error("puppet metadata: store does not support puppet metadata", "repo", repoName)
|
||||
return
|
||||
}
|
||||
if err := store.InsertPuppetMetadata(ctx, meta); err != nil {
|
||||
slog.Error("puppet metadata: insert failed", "repo", repoName, "path", storagePath, "error", err)
|
||||
return
|
||||
}
|
||||
slog.Info("puppet metadata: parsed", "repo", repoName, "module", meta.Slug, "version", meta.Version)
|
||||
}
|
||||
|
||||
func (p *Provider) AfterDelete(ctx context.Context, repoName, storagePath string, db provider.MetadataDeleter) error {
|
||||
deleter, ok := db.(provider.PuppetMetadataDeleter)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return deleter.DeletePuppetMetadata(ctx, repoName, storagePath)
|
||||
}
|
||||
|
||||
const maxMetadataJSON = 1 << 20
|
||||
|
||||
var (
|
||||
maxModuleTarball int64 = 100 << 20
|
||||
maxModuleScan int64 = 200 << 20
|
||||
)
|
||||
|
||||
// parseModule reads the top-level <dir>/metadata.json from a module tarball,
|
||||
// scanning at most maxModuleScan decompressed bytes.
|
||||
func parseModule(r io.Reader) (*provider.PuppetMetadata, error) {
|
||||
zr, err := gzip.NewReader(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scan := &io.LimitedReader{R: zr, N: maxModuleScan}
|
||||
tr := tar.NewReader(scan)
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if scan.N <= 0 {
|
||||
return nil, errors.New("module tarball exceeds decompressed scan limit before metadata.json")
|
||||
}
|
||||
if err == io.EOF {
|
||||
return nil, errors.New("no metadata.json in module tarball")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dir, file := path.Split(strings.TrimPrefix(path.Clean(hdr.Name), "./"))
|
||||
if hdr.Typeflag != tar.TypeReg || file != "metadata.json" || strings.Count(dir, "/") != 1 {
|
||||
continue
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(tr, maxMetadataJSON))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var md struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &md); err != nil {
|
||||
return nil, fmt.Errorf("parse metadata.json: %w", err)
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Provider) ServeLocalIndex(w http.ResponseWriter, r *http.Request, files provider.FileStore, repoName, reqPath string) bool {
|
||||
clean := strings.TrimPrefix(path.Clean("/"+reqPath), "/")
|
||||
var list func(slug string) ([]provider.PuppetMetadata, error)
|
||||
if reader, ok := files.(provider.PuppetMetadataReader); ok {
|
||||
list = func(slug string) ([]provider.PuppetMetadata, error) {
|
||||
metas, err := reader.ListPuppetMetadataEntries(r.Context(), repoName, slug)
|
||||
sortReleases(metas)
|
||||
return metas, err
|
||||
}
|
||||
} else {
|
||||
list = func(string) ([]provider.PuppetMetadata, error) {
|
||||
return nil, errors.New("puppet metadata not available")
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case clean == "v3/releases":
|
||||
serveReleaseList(w, r, list)
|
||||
case strings.HasPrefix(clean, "v3/releases/"):
|
||||
slug, version, ok := parseRelease(strings.TrimPrefix(clean, "v3/releases/"))
|
||||
if !ok {
|
||||
notFound(w)
|
||||
return true
|
||||
}
|
||||
metas, err := list(slug)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return true
|
||||
}
|
||||
for _, m := range metas {
|
||||
if m.Version == version {
|
||||
writeJSON(w, releaseObject(m))
|
||||
return true
|
||||
}
|
||||
}
|
||||
notFound(w)
|
||||
case strings.HasPrefix(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)
|
||||
return true
|
||||
}
|
||||
writeJSON(w, moduleObject(metas))
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (p *Provider) GenerateLocalIndex(ctx context.Context, files provider.FileStore, repoName, path string) ([]byte, error) {
|
||||
return nil, fmt.Errorf("puppet local index generation for virtual repos not supported")
|
||||
}
|
||||
|
||||
func serveReleaseList(w http.ResponseWriter, r *http.Request, list func(string) ([]provider.PuppetMetadata, error)) {
|
||||
q := r.URL.Query()
|
||||
slug := normalizeSlug(q.Get("module"))
|
||||
limit, err := strconv.Atoi(q.Get("limit"))
|
||||
if err != nil || limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
limit = min(limit, 100)
|
||||
offset, err := strconv.Atoi(q.Get("offset"))
|
||||
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))
|
||||
}
|
||||
|
||||
page := func(off int) any {
|
||||
if off < 0 || off >= len(metas) && off != 0 {
|
||||
return nil
|
||||
}
|
||||
v := url.Values{"limit": {strconv.Itoa(limit)}, "offset": {strconv.Itoa(off)}}
|
||||
if slug != "" {
|
||||
v.Set("module", slug)
|
||||
}
|
||||
return "/v3/releases?" + v.Encode()
|
||||
}
|
||||
var previous any
|
||||
if offset > 0 {
|
||||
previous = page(max(offset-limit, 0))
|
||||
}
|
||||
writeJSON(w, map[string]any{
|
||||
"pagination": map[string]any{
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"first": page(0),
|
||||
"previous": previous,
|
||||
"current": page(offset),
|
||||
"next": page(offset + limit),
|
||||
"total": len(metas),
|
||||
},
|
||||
"results": results,
|
||||
})
|
||||
}
|
||||
|
||||
func owner(slug string) map[string]any {
|
||||
name, _, _ := strings.Cut(slug, "-")
|
||||
return map[string]any{"uri": "/v3/users/" + name, "slug": name, "username": name}
|
||||
}
|
||||
|
||||
func moduleRef(slug string) map[string]any {
|
||||
_, name, _ := strings.Cut(slug, "-")
|
||||
return map[string]any{"uri": "/v3/modules/" + slug, "slug": slug, "name": name, "deprecated_at": nil, "owner": owner(slug)}
|
||||
}
|
||||
|
||||
const forgeTime = "2006-01-02 15:04:05 -0700"
|
||||
|
||||
func releaseObject(m provider.PuppetMetadata) map[string]any {
|
||||
slug := m.Slug + "-" + m.Version
|
||||
return map[string]any{
|
||||
"uri": "/v3/releases/" + slug,
|
||||
"slug": slug,
|
||||
"module": moduleRef(m.Slug),
|
||||
"version": m.Version,
|
||||
"metadata": json.RawMessage(m.MetadataJSON),
|
||||
"tags": []string{},
|
||||
"supported": false,
|
||||
"pdk": false,
|
||||
"file_uri": "/" + m.FilePath,
|
||||
"file_size": m.Size,
|
||||
"file_md5": m.MD5,
|
||||
"file_sha256": strings.TrimPrefix(m.ContentHash, "sha256:"),
|
||||
"downloads": 0,
|
||||
"readme": nil,
|
||||
"changelog": nil,
|
||||
"license": nil,
|
||||
"created_at": m.CreatedAt.Format(forgeTime),
|
||||
"updated_at": m.CreatedAt.Format(forgeTime),
|
||||
"deleted_at": nil,
|
||||
}
|
||||
}
|
||||
|
||||
// moduleObject expects metas sorted newest version first.
|
||||
func moduleObject(metas []provider.PuppetMetadata) map[string]any {
|
||||
obj := moduleRef(metas[0].Slug)
|
||||
releases := make([]map[string]any, 0, len(metas))
|
||||
for _, m := range metas {
|
||||
slug := m.Slug + "-" + m.Version
|
||||
releases = append(releases, map[string]any{
|
||||
"uri": "/v3/releases/" + slug, "slug": slug, "version": m.Version,
|
||||
"file_uri": "/" + m.FilePath, "file_size": m.Size, "supported": false,
|
||||
"created_at": m.CreatedAt.Format(forgeTime), "deleted_at": nil,
|
||||
})
|
||||
}
|
||||
obj["current_release"] = releaseObject(metas[0])
|
||||
obj["releases"] = releases
|
||||
obj["downloads"] = 0
|
||||
obj["created_at"] = metas[len(metas)-1].CreatedAt.Format(forgeTime)
|
||||
obj["updated_at"] = metas[0].CreatedAt.Format(forgeTime)
|
||||
return obj
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetEscapeHTML(false)
|
||||
_ = enc.Encode(v)
|
||||
}
|
||||
|
||||
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"]}`)
|
||||
}
|
||||
|
||||
// sortReleases orders by module slug, then newest semver first.
|
||||
func sortReleases(metas []provider.PuppetMetadata) {
|
||||
slices.SortStableFunc(metas, func(a, b provider.PuppetMetadata) int {
|
||||
if c := strings.Compare(a.Slug, b.Slug); c != 0 {
|
||||
return c
|
||||
}
|
||||
return compareSemver(b.Version, a.Version)
|
||||
})
|
||||
}
|
||||
|
||||
func compareSemver(a, b string) int {
|
||||
a, _, _ = strings.Cut(a, "+")
|
||||
b, _, _ = strings.Cut(b, "+")
|
||||
aCore, aPre, aHasPre := strings.Cut(a, "-")
|
||||
bCore, bPre, bHasPre := strings.Cut(b, "-")
|
||||
if c := compareIdents(strings.Split(aCore, "."), strings.Split(bCore, ".")); c != 0 {
|
||||
return c
|
||||
}
|
||||
switch {
|
||||
case aHasPre && !bHasPre:
|
||||
return -1
|
||||
case !aHasPre && bHasPre:
|
||||
return 1
|
||||
}
|
||||
return compareIdents(strings.Split(aPre, "."), strings.Split(bPre, "."))
|
||||
}
|
||||
|
||||
func compareIdents(a, b []string) int {
|
||||
for i := 0; i < len(a) && i < len(b); i++ {
|
||||
an, aErr := strconv.Atoi(a[i])
|
||||
bn, bErr := strconv.Atoi(b[i])
|
||||
var c int
|
||||
switch {
|
||||
case aErr == nil && bErr == nil:
|
||||
c = an - bn
|
||||
case aErr == nil:
|
||||
c = -1
|
||||
case bErr == nil:
|
||||
c = 1
|
||||
default:
|
||||
c = strings.Compare(a[i], b[i])
|
||||
}
|
||||
if c != 0 {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return len(a) - len(b)
|
||||
}
|
||||
@@ -1,301 +0,0 @@
|
||||
package puppet
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/provider"
|
||||
)
|
||||
|
||||
func moduleTarball(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{dir + "/metadata.json": metadata, dir + "/manifests/init.pp": "class hello {}\n"} {
|
||||
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: int64(len(body))}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = tw.Write([]byte(body))
|
||||
}
|
||||
_ = tw.Close()
|
||||
_ = zw.Close()
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
const helloMetadata = `{"name":"unkin-hello","version":"0.1.0","dependencies":[{"name":"puppetlabs/stdlib","version_requirement":">= 4.0.0"}]}`
|
||||
|
||||
type fakeBlobs struct{ data []byte }
|
||||
|
||||
func (f fakeBlobs) Download(context.Context, string) (io.ReadCloser, int64, error) {
|
||||
return io.NopCloser(bytes.NewReader(f.data)), int64(len(f.data)), nil
|
||||
}
|
||||
|
||||
type fakeStore struct{ inserted *provider.PuppetMetadata }
|
||||
|
||||
func (f *fakeStore) InsertRPMMetadata(context.Context, *provider.RPMMetadata) error { return nil }
|
||||
func (f *fakeStore) InsertDebMetadata(context.Context, *provider.DebMetadata) error { return nil }
|
||||
func (f *fakeStore) InsertPuppetMetadata(_ context.Context, m *provider.PuppetMetadata) error {
|
||||
f.inserted = m
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeReader struct{ metas []provider.PuppetMetadata }
|
||||
|
||||
func (f fakeReader) ListPuppetMetadataEntries(_ context.Context, _, slug string) ([]provider.PuppetMetadata, error) {
|
||||
var out []provider.PuppetMetadata
|
||||
for _, m := range f.metas {
|
||||
if slug == "" || m.Slug == slug {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (fakeReader) ListFilesByPrefix(context.Context, string, string) ([]provider.FileEntry, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (fakeReader) ListPackages(context.Context, string) ([]string, error) { return nil, nil }
|
||||
|
||||
func TestValidateUpload(t *testing.T) {
|
||||
p := &Provider{}
|
||||
sp, ct, err := p.ValidateUpload("some/dir/Unkin-hello-0.1.0.tar.gz")
|
||||
if err != nil || sp != "v3/files/unkin-hello-0.1.0.tar.gz" || ct != "application/gzip" {
|
||||
t.Errorf("sp=%q ct=%q err=%v", sp, ct, err)
|
||||
}
|
||||
for _, bad := range []string{"hello-0.1.0.tar.gz", "unkin-hello-0.1.tar.gz", "unkin-hello-0.1.0.zip", "unkin-Hello_x-0.1.0.tgz"} {
|
||||
if _, _, err := p.ValidateUpload(bad); err == nil {
|
||||
t.Errorf("%q accepted", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAfterUploadRecordsMetadata(t *testing.T) {
|
||||
raw := moduleTarball(t, "unkin-hello-0.1.0", helloMetadata)
|
||||
store := &fakeStore{}
|
||||
(&Provider{}).AfterUpload(context.Background(), "forge", "v3/files/unkin-hello-0.1.0.tar.gz", "sha256:abc", fakeBlobs{raw}, store)
|
||||
m := store.inserted
|
||||
if m == nil {
|
||||
t.Fatal("nothing inserted")
|
||||
}
|
||||
sum := md5.Sum(raw)
|
||||
if m.Slug != "unkin-hello" || m.Version != "0.1.0" || m.MD5 != hex.EncodeToString(sum[:]) || m.Size != int64(len(raw)) || m.MetadataJSON != helloMetadata {
|
||||
t.Errorf("got %+v", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAfterUploadRejectsMismatchedFilename(t *testing.T) {
|
||||
raw := moduleTarball(t, "unkin-hello-0.2.0", `{"name":"unkin-hello","version":"0.2.0"}`)
|
||||
store := &fakeStore{}
|
||||
(&Provider{}).AfterUpload(context.Background(), "forge", "v3/files/unkin-hello-0.1.0.tar.gz", "sha256:abc", fakeBlobs{raw}, store)
|
||||
if store.inserted != nil {
|
||||
t.Error("mismatched version indexed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseModuleRequiresTopLevelMetadata(t *testing.T) {
|
||||
if _, err := parseModule(bytes.NewReader(moduleTarball(t, "a/b", helloMetadata))); err == nil {
|
||||
t.Error("nested metadata.json accepted")
|
||||
}
|
||||
if _, err := parseModule(bytes.NewReader(moduleTarball(t, "x", `{"name":"unkin-hello"}`))); err == nil {
|
||||
t.Error("missing version accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func tarballOf(t *testing.T, entries ...*tar.Header) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
zw := gzip.NewWriter(&buf)
|
||||
tw := tar.NewWriter(zw)
|
||||
for _, h := range entries {
|
||||
if err := tw.WriteHeader(h); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if h.Typeflag == tar.TypeReg {
|
||||
body := helloMetadata
|
||||
if !strings.HasSuffix(h.Name, "metadata.json") {
|
||||
body = strings.Repeat("x", int(h.Size))
|
||||
}
|
||||
_, _ = tw.Write([]byte(body))
|
||||
}
|
||||
}
|
||||
_ = tw.Close()
|
||||
_ = zw.Close()
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func TestParseModuleSkipsNonRegularMetadata(t *testing.T) {
|
||||
raw := tarballOf(t,
|
||||
&tar.Header{Name: "unkin-hello-0.1.0/metadata.json", Typeflag: tar.TypeSymlink, Linkname: "/etc/passwd"},
|
||||
&tar.Header{Name: "unkin-hello-0.1.0/metadata.json/", Typeflag: tar.TypeDir, Mode: 0o755},
|
||||
&tar.Header{Name: "unkin-hello-0.1.0/metadata.json", Typeflag: tar.TypeReg, Mode: 0o644, Size: int64(len(helloMetadata))},
|
||||
)
|
||||
meta, err := parseModule(bytes.NewReader(raw))
|
||||
if err != nil || meta.Slug != "unkin-hello" || meta.Version != "0.1.0" {
|
||||
t.Fatalf("got %v, %v", meta, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseModuleCapsDecompressedScan(t *testing.T) {
|
||||
defer func(n int64) { maxModuleScan = n }(maxModuleScan)
|
||||
maxModuleScan = 64 << 10
|
||||
raw := tarballOf(t,
|
||||
&tar.Header{Name: "unkin-hello-0.1.0/big", Typeflag: tar.TypeReg, Mode: 0o644, Size: 1 << 20},
|
||||
&tar.Header{Name: "unkin-hello-0.1.0/metadata.json", Typeflag: tar.TypeReg, Mode: 0o644, Size: int64(len(helloMetadata))},
|
||||
)
|
||||
if _, err := parseModule(bytes.NewReader(raw)); err == nil || !strings.Contains(err.Error(), "scan limit") {
|
||||
t.Fatalf("expected scan limit error, got %v", err)
|
||||
}
|
||||
maxModuleScan = 2 << 20
|
||||
if _, err := parseModule(bytes.NewReader(raw)); err != nil {
|
||||
t.Fatalf("under cap: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAfterUploadCapsTarballSize(t *testing.T) {
|
||||
defer func(n int64) { maxModuleTarball = n }(maxModuleTarball)
|
||||
raw := moduleTarball(t, "unkin-hello-0.1.0", helloMetadata)
|
||||
maxModuleTarball = int64(len(raw)) - 1
|
||||
store := &fakeStore{}
|
||||
(&Provider{}).AfterUpload(context.Background(), "forge", "v3/files/unkin-hello-0.1.0.tar.gz", "sha256:abc", fakeBlobs{raw}, store)
|
||||
if store.inserted != nil {
|
||||
t.Error("oversized tarball indexed")
|
||||
}
|
||||
maxModuleTarball = int64(len(raw))
|
||||
(&Provider{}).AfterUpload(context.Background(), "forge", "v3/files/unkin-hello-0.1.0.tar.gz", "sha256:abc", fakeBlobs{raw}, store)
|
||||
if store.inserted == nil {
|
||||
t.Error("tarball at cap not indexed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseListClampsPagination(t *testing.T) {
|
||||
files := releases("0.1.0", "0.2.0", "0.3.0")
|
||||
for _, q := range []string{"offset=9223372036854775807", "offset=9223372036854775807&limit=9223372036854775807", "offset=-5&limit=-5", "limit=9223372036854775807"} {
|
||||
code, body := get(t, files, "v3/releases?module=unkin-hello&"+q)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("%s: code %d", q, code)
|
||||
}
|
||||
pg := body["pagination"].(map[string]any)
|
||||
if l := pg["limit"].(float64); l < 1 || l > 100 {
|
||||
t.Errorf("%s: limit %v", q, l)
|
||||
}
|
||||
if o := pg["offset"].(float64); o < 0 || o > 3 {
|
||||
t.Errorf("%s: offset %v", q, o)
|
||||
}
|
||||
}
|
||||
_, body := get(t, files, "v3/releases?module=unkin-hello&offset=9223372036854775807")
|
||||
if len(body["results"].([]any)) != 0 || body["pagination"].(map[string]any)["next"] != nil {
|
||||
t.Errorf("past-end page: %v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func get(t *testing.T, files provider.FileStore, target string) (int, map[string]any) {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/x/"+target, nil)
|
||||
path, _, _ := strings.Cut(target, "?")
|
||||
if !(&Provider{}).ServeLocalIndex(rec, req, files, "forge", path) {
|
||||
return 0, nil
|
||||
}
|
||||
var body map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &body)
|
||||
return rec.Code, body
|
||||
}
|
||||
|
||||
func releases(versions ...string) fakeReader {
|
||||
var f fakeReader
|
||||
for i, v := range versions {
|
||||
f.metas = append(f.metas, provider.PuppetMetadata{
|
||||
FilePath: "v3/files/unkin-hello-" + v + ".tar.gz", ContentHash: "sha256:feed", Slug: "unkin-hello",
|
||||
Version: v, MetadataJSON: `{"name":"unkin-hello","version":"` + v + `"}`, MD5: "m" + v,
|
||||
CreatedAt: time.Unix(int64(i), 0),
|
||||
})
|
||||
}
|
||||
f.metas = append(f.metas, provider.PuppetMetadata{Slug: "other-mod", Version: "9.9.9", MetadataJSON: `{}`})
|
||||
return f
|
||||
}
|
||||
|
||||
func TestReleaseListPaginatesNewestFirst(t *testing.T) {
|
||||
files := releases("0.1.0", "0.10.0", "0.2.0", "1.0.0-rc.1", "1.0.0")
|
||||
code, body := get(t, files, "v3/releases?module=unkin/hello&limit=2")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("code %d", code)
|
||||
}
|
||||
results := body["results"].([]any)
|
||||
pg := body["pagination"].(map[string]any)
|
||||
if len(results) != 2 || results[0].(map[string]any)["version"] != "1.0.0" || results[1].(map[string]any)["version"] != "1.0.0-rc.1" {
|
||||
t.Fatalf("page 1: %v", results)
|
||||
}
|
||||
if pg["total"] != float64(5) || pg["next"] != "/v3/releases?limit=2&module=unkin-hello&offset=2" || pg["previous"] != nil {
|
||||
t.Errorf("pagination: %v", pg)
|
||||
}
|
||||
|
||||
_, body = get(t, files, "v3/releases?module=unkin-hello&limit=2&offset=4")
|
||||
results = body["results"].([]any)
|
||||
pg = body["pagination"].(map[string]any)
|
||||
if len(results) != 1 || results[0].(map[string]any)["version"] != "0.1.0" || pg["next"] != nil || pg["previous"] != "/v3/releases?limit=2&module=unkin-hello&offset=2" {
|
||||
t.Errorf("last page: %v %v", results, pg)
|
||||
}
|
||||
|
||||
_, body = get(t, files, "v3/releases?module=nobody-none")
|
||||
if len(body["results"].([]any)) != 0 {
|
||||
t.Error("unknown module returned results")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseObject(t *testing.T) {
|
||||
code, body := get(t, releases("0.1.0"), "v3/releases/unkin-hello-0.1.0")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("code %d", code)
|
||||
}
|
||||
if body["file_uri"] != "/v3/files/unkin-hello-0.1.0.tar.gz" || body["file_md5"] != "m0.1.0" || body["file_sha256"] != "feed" {
|
||||
t.Errorf("release: %v", body)
|
||||
}
|
||||
if body["metadata"].(map[string]any)["name"] != "unkin-hello" || body["module"].(map[string]any)["owner"].(map[string]any)["username"] != "unkin" {
|
||||
t.Errorf("release refs: %v", body)
|
||||
}
|
||||
if code, _ := get(t, releases("0.1.0"), "v3/releases/unkin-hello-0.2.0"); code != http.StatusNotFound {
|
||||
t.Errorf("missing version: %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleObject(t *testing.T) {
|
||||
code, body := get(t, releases("0.9.0", "0.10.0"), "v3/modules/unkin-hello")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("code %d", code)
|
||||
}
|
||||
if body["slug"] != "unkin-hello" || body["name"] != "hello" || body["current_release"].(map[string]any)["version"] != "0.10.0" || len(body["releases"].([]any)) != 2 {
|
||||
t.Errorf("module: %v", body)
|
||||
}
|
||||
if code, _ := get(t, releases(), "v3/modules/unkin-missing"); code != http.StatusNotFound {
|
||||
t.Errorf("missing module: %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilesFallThrough(t *testing.T) {
|
||||
if code, _ := get(t, releases(), "v3/files/unkin-hello-0.1.0.tar.gz"); code != 0 {
|
||||
t.Error("file downloads must fall through to the stored file")
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
t.Errorf("%s should sort before %s", ordered[i-1], ordered[i])
|
||||
}
|
||||
}
|
||||
if compareSemver("1.0.0+a", "1.0.0+b") != 0 {
|
||||
t.Error("build metadata must not affect precedence")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package virtual
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"path"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// debIndexRe matches the binary Packages indexes a virtual merges: the flat
|
||||
// repo's root Packages and a suite's <component>/binary-<arch>/Packages.
|
||||
var debIndexRe = regexp.MustCompile(`^([^/]+/binary-[^/]+/)?Packages(\.[a-z0-9]+)?$`)
|
||||
|
||||
// debByHashRe matches an index requested by its SHA256 (Acquire-By-Hash).
|
||||
var debByHashRe = regexp.MustCompile(`^([^/]+/binary-[^/]+/)?by-hash/SHA256/[0-9a-f]{64}$`)
|
||||
|
||||
// debCompressions is the order a member's index variants are fetched in.
|
||||
var debCompressions = []string{".xz", ".gz", ".zst", ".bz2", ""}
|
||||
|
||||
// DebMember is one member's Release plus its decompressed Packages indexes,
|
||||
// keyed by index directory relative to the Release ("" for a flat repo).
|
||||
type DebMember struct {
|
||||
RemoteName string
|
||||
Release []byte
|
||||
Packages map[string][]byte
|
||||
}
|
||||
|
||||
// DebRepo is a merged apt repo: Release plus the indexes it lists, keyed by
|
||||
// their path relative to the Release.
|
||||
type DebRepo struct {
|
||||
Release []byte
|
||||
Files map[string][]byte
|
||||
}
|
||||
|
||||
type debRelease struct {
|
||||
fields map[string]string
|
||||
files []string
|
||||
}
|
||||
|
||||
// parseDebRelease reads a Release file's single-line fields and the names
|
||||
// listed in its checksum sections.
|
||||
func parseDebRelease(body []byte) debRelease {
|
||||
rel := debRelease{fields: map[string]string{}}
|
||||
inSums := false
|
||||
seen := map[string]bool{}
|
||||
for line := range strings.SplitSeq(string(body), "\n") {
|
||||
line = strings.TrimRight(line, "\r")
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if line[0] == ' ' || line[0] == '\t' {
|
||||
if f := strings.Fields(line); inSums && len(f) == 3 && !seen[f[2]] {
|
||||
seen[f[2]] = true
|
||||
rel.files = append(rel.files, f[2])
|
||||
}
|
||||
continue
|
||||
}
|
||||
k, v, _ := strings.Cut(line, ":")
|
||||
v = strings.TrimSpace(v)
|
||||
inSums = v == "" && (k == "MD5Sum" || strings.HasPrefix(k, "SHA"))
|
||||
if _, ok := rel.fields[k]; !ok {
|
||||
rel.fields[k] = v
|
||||
}
|
||||
}
|
||||
return rel
|
||||
}
|
||||
|
||||
// debIndexFiles returns, per index directory, the variant of its Packages file
|
||||
// to fetch from a member's Release listing.
|
||||
func debIndexFiles(rel debRelease) map[string]string {
|
||||
listed := map[string]bool{}
|
||||
for _, f := range rel.files {
|
||||
listed[f] = true
|
||||
}
|
||||
out := map[string]string{}
|
||||
for _, f := range rel.files {
|
||||
m := debIndexRe.FindStringSubmatch(f)
|
||||
if m == nil || out[m[1]] != "" {
|
||||
continue
|
||||
}
|
||||
for _, c := range debCompressions {
|
||||
if listed[m[1]+"Packages"+c] {
|
||||
out[m[1]] = m[1] + "Packages" + c
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func splitStanzas(body []byte) []string {
|
||||
var out []string
|
||||
for s := range strings.SplitSeq(strings.ReplaceAll(string(body), "\r\n", "\n"), "\n\n") {
|
||||
if s = strings.Trim(s, "\n"); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func stanzaField(stanza, key string) string {
|
||||
for line := range strings.SplitSeq(stanza, "\n") {
|
||||
if k, v, ok := strings.Cut(line, ":"); ok && k == key {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func unionFields(dst []string, field string) []string {
|
||||
for f := range strings.FieldsSeq(field) {
|
||||
if !slices.Contains(dst, f) {
|
||||
dst = append(dst, f)
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
func parseDebDate(s string) (time.Time, bool) {
|
||||
for _, layout := range []string{time.RFC1123Z, time.RFC1123} {
|
||||
if t, err := time.Parse(layout, s); err == nil {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
// MergeDeb merges members into one apt repo. Members are in priority order: the
|
||||
// first member to carry a (Package, Version, Architecture) in an index wins;
|
||||
// stanzas missing any of those are always kept. Filename is prefixed with the
|
||||
// owning member's name so the virtual can route downloads back to it.
|
||||
func MergeDeb(members []DebMember) *DebRepo {
|
||||
var suite, codename string
|
||||
var arches, comps, dirs []string
|
||||
date := time.Unix(0, 0)
|
||||
for _, m := range members {
|
||||
rel := parseDebRelease(m.Release)
|
||||
if suite == "" {
|
||||
suite, codename = rel.fields["Suite"], rel.fields["Codename"]
|
||||
}
|
||||
arches = unionFields(arches, rel.fields["Architectures"])
|
||||
comps = unionFields(comps, rel.fields["Components"])
|
||||
if t, ok := parseDebDate(rel.fields["Date"]); ok && t.After(date) {
|
||||
date = t
|
||||
}
|
||||
for d := range m.Packages {
|
||||
if !slices.Contains(dirs, d) {
|
||||
dirs = append(dirs, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
slices.Sort(dirs)
|
||||
|
||||
repo := &DebRepo{Files: map[string][]byte{}}
|
||||
for _, d := range dirs {
|
||||
seen := map[string]bool{}
|
||||
var b bytes.Buffer
|
||||
for _, m := range members {
|
||||
for _, s := range splitStanzas(m.Packages[d]) {
|
||||
pkg, ver, arch := stanzaField(s, "Package"), stanzaField(s, "Version"), stanzaField(s, "Architecture")
|
||||
if pkg != "" && ver != "" && arch != "" {
|
||||
key := pkg + "\x00" + ver + "\x00" + arch
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
}
|
||||
if fn := stanzaField(s, "Filename"); fn != "" {
|
||||
s = rewriteFilename(s, m.RemoteName+"/"+strings.TrimPrefix(path.Clean("/"+fn), "/"))
|
||||
}
|
||||
b.WriteString(s)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
}
|
||||
gz := gzipDeterministic(b.Bytes())
|
||||
repo.Files[d+"Packages"] = b.Bytes()
|
||||
repo.Files[d+"Packages.gz"] = gz
|
||||
repo.Files[d+"by-hash/SHA256/"+sha256Hex(b.Bytes())] = b.Bytes()
|
||||
repo.Files[d+"by-hash/SHA256/"+sha256Hex(gz)] = gz
|
||||
}
|
||||
|
||||
var r bytes.Buffer
|
||||
if suite != "" {
|
||||
fmt.Fprintf(&r, "Suite: %s\n", suite)
|
||||
}
|
||||
if codename != "" {
|
||||
fmt.Fprintf(&r, "Codename: %s\n", codename)
|
||||
}
|
||||
fmt.Fprintf(&r, "Date: %s\n", date.UTC().Format(time.RFC1123Z))
|
||||
fmt.Fprintf(&r, "Architectures: %s\n", strings.Join(arches, " "))
|
||||
if len(comps) > 0 {
|
||||
fmt.Fprintf(&r, "Components: %s\n", strings.Join(comps, " "))
|
||||
}
|
||||
r.WriteString("Acquire-By-Hash: yes\n")
|
||||
for _, sec := range []struct {
|
||||
name string
|
||||
sum func([]byte) string
|
||||
}{{"MD5Sum", md5Hex}, {"SHA256", sha256Hex}} {
|
||||
r.WriteString(sec.name + ":\n")
|
||||
for _, d := range dirs {
|
||||
for _, n := range []string{d + "Packages", d + "Packages.gz"} {
|
||||
fmt.Fprintf(&r, " %s %d %s\n", sec.sum(repo.Files[n]), len(repo.Files[n]), n)
|
||||
}
|
||||
}
|
||||
}
|
||||
repo.Release = r.Bytes()
|
||||
return repo
|
||||
}
|
||||
|
||||
func rewriteFilename(stanza, fn string) string {
|
||||
lines := strings.Split(stanza, "\n")
|
||||
for i, l := range lines {
|
||||
if strings.HasPrefix(l, "Filename:") {
|
||||
lines[i] = "Filename: " + fn
|
||||
break
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// debIndexPath splits an index request into its Release directory and the file
|
||||
// under it. ok is false for anything a deb virtual does not merge.
|
||||
func debIndexPath(p string) (base, name string, ok bool) {
|
||||
if rest, found := strings.CutPrefix(p, "dists/"); found {
|
||||
suite, n, found := strings.Cut(rest, "/")
|
||||
if !found || suite == "" {
|
||||
return "", "", false
|
||||
}
|
||||
base, p = "dists/"+suite+"/", n
|
||||
}
|
||||
if p != "Release" && !debIndexRe.MatchString(p) && !debByHashRe.MatchString(p) {
|
||||
return "", "", false
|
||||
}
|
||||
return base, p, true
|
||||
}
|
||||
|
||||
func md5Hex(data []byte) string {
|
||||
h := md5.Sum(data)
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package virtual
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"maps"
|
||||
"path"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func debStanza(pkg, ver, arch, filename string) string {
|
||||
return fmt.Sprintf("Package: %s\nVersion: %s\nArchitecture: %s\nDescription: test\n multi-line body\nFilename: %s\nSize: 1\n", pkg, ver, arch, filename)
|
||||
}
|
||||
|
||||
func flatDebMember(name, date string, stanzas ...string) DebMember {
|
||||
rel := "Date: " + date + "\nArchitectures: amd64\nAcquire-By-Hash: no\nSHA256:\n x 1 Packages\n x 1 Packages.gz\n"
|
||||
return DebMember{RemoteName: name, Release: []byte(rel), Packages: map[string][]byte{"": []byte(strings.Join(stanzas, "\n"))}}
|
||||
}
|
||||
|
||||
func TestMergeDebDedupesByMemberOrder(t *testing.T) {
|
||||
repo := MergeDeb([]DebMember{
|
||||
flatDebMember("a", "Mon, 05 Oct 2026 10:00:00 +0000",
|
||||
debStanza("foo", "1.0", "amd64", "pool/foo_1.0_amd64.deb"),
|
||||
debStanza("bar", "2.0", "amd64", "/pool/bar.deb"),
|
||||
debStanza("dot", "1.0", "amd64", "./dot.deb"),
|
||||
"Package: noversion\nFilename: nv1.deb\n",
|
||||
"Package: noversion\nFilename: nv2.deb\n"),
|
||||
flatDebMember("b", "Tue, 06 Oct 2026 10:00:00 UTC",
|
||||
debStanza("foo", "1.0", "amd64", "pool/foo-from-b.deb"),
|
||||
debStanza("foo", "1.0", "arm64", "pool/foo_arm64.deb"),
|
||||
debStanza("foo", "1.1", "amd64", "o/r/releases/download/v1/foo.deb")),
|
||||
})
|
||||
pk := string(repo.Files["Packages"])
|
||||
stanzas := splitStanzas(repo.Files["Packages"])
|
||||
if len(stanzas) != 7 {
|
||||
t.Fatalf("want 7 stanzas (one duplicate dropped), got %d:\n%s", len(stanzas), pk)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Filename: a/pool/foo_1.0_amd64.deb",
|
||||
"Filename: a/pool/bar.deb",
|
||||
"Filename: a/dot.deb",
|
||||
"Filename: a/nv1.deb",
|
||||
"Filename: a/nv2.deb",
|
||||
"Filename: b/pool/foo_arm64.deb",
|
||||
"Filename: b/o/r/releases/download/v1/foo.deb",
|
||||
" multi-line body",
|
||||
} {
|
||||
if !strings.Contains(pk, want) {
|
||||
t.Errorf("merged Packages missing %q:\n%s", want, pk)
|
||||
}
|
||||
}
|
||||
if strings.Contains(pk, "foo-from-b") {
|
||||
t.Fatalf("second member's duplicate must lose:\n%s", pk)
|
||||
}
|
||||
if !bytes.Equal(gunzip(t, repo.Files["Packages.gz"]), repo.Files["Packages"]) {
|
||||
t.Fatal("Packages.gz does not match Packages")
|
||||
}
|
||||
if !strings.Contains(string(repo.Release), "Date: Tue, 06 Oct 2026 10:00:00 +0000\n") {
|
||||
t.Fatalf("Date should be the newest member date:\n%s", repo.Release)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeDebReleaseHashesFiles(t *testing.T) {
|
||||
repo := MergeDeb([]DebMember{flatDebMember("a", "", debStanza("foo", "1", "amd64", "pool/foo.deb"))})
|
||||
rel := parseDebRelease(repo.Release)
|
||||
if got := slices.Sorted(slices.Values(rel.files)); !slices.Equal(got, []string{"Packages", "Packages.gz"}) {
|
||||
t.Fatalf("Release lists %v", got)
|
||||
}
|
||||
inSHA := false
|
||||
checked := 0
|
||||
for line := range strings.SplitSeq(string(repo.Release), "\n") {
|
||||
if !strings.HasPrefix(line, " ") {
|
||||
inSHA = line == "SHA256:"
|
||||
continue
|
||||
}
|
||||
var sum, name string
|
||||
var size int
|
||||
if _, err := fmt.Sscan(line, &sum, &size, &name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := repo.Files[name]
|
||||
want := sha256Hex(body)
|
||||
if !inSHA {
|
||||
h := md5.Sum(body)
|
||||
want = hex.EncodeToString(h[:])
|
||||
}
|
||||
if sum != want || size != len(body) {
|
||||
t.Errorf("%s: Release has %s %d, file is %s %d", name, sum, size, want, len(body))
|
||||
}
|
||||
checked++
|
||||
}
|
||||
if checked != 4 {
|
||||
t.Fatalf("checked %d Release entries, want 4:\n%s", checked, repo.Release)
|
||||
}
|
||||
if !bytes.Equal(MergeDeb([]DebMember{flatDebMember("a", "", debStanza("foo", "1", "amd64", "pool/foo.deb"))}).Release, repo.Release) {
|
||||
t.Fatal("merge must be deterministic")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeDebSuiteLayout(t *testing.T) {
|
||||
rel := func(comps string) []byte {
|
||||
return []byte("Suite: stable\nCodename: trixie\nArchitectures: amd64 arm64\nComponents: " + comps + "\nSHA256:\n x 1 main/binary-amd64/Packages.xz\n")
|
||||
}
|
||||
repo := MergeDeb([]DebMember{
|
||||
{RemoteName: "deb1", Release: rel("main"), Packages: map[string][]byte{"main/binary-amd64/": []byte(debStanza("foo", "1", "amd64", "pool/main/f/foo.deb"))}},
|
||||
{RemoteName: "deb2", Release: rel("main contrib"), Packages: map[string][]byte{
|
||||
"main/binary-amd64/": []byte(debStanza("bar", "1", "amd64", "pool/main/b/bar.deb")),
|
||||
"contrib/binary-amd64/": []byte(debStanza("baz", "1", "amd64", "pool/contrib/b/baz.deb")),
|
||||
}},
|
||||
})
|
||||
for _, want := range []string{"Suite: stable\n", "Codename: trixie\n", "Components: main contrib\n", "Architectures: amd64 arm64\n"} {
|
||||
if !strings.Contains(string(repo.Release), want) {
|
||||
t.Errorf("Release missing %q:\n%s", want, repo.Release)
|
||||
}
|
||||
}
|
||||
want := []string{"contrib/binary-amd64/Packages", "contrib/binary-amd64/Packages.gz", "main/binary-amd64/Packages", "main/binary-amd64/Packages.gz"}
|
||||
if got := slices.Sorted(slices.Values(parseDebRelease(repo.Release).files)); !slices.Equal(got, want) {
|
||||
t.Fatalf("Release lists %v, want %v", got, want)
|
||||
}
|
||||
if !strings.Contains(string(repo.Release), "Acquire-By-Hash: yes\n") {
|
||||
t.Fatalf("Release must advertise by-hash:\n%s", repo.Release)
|
||||
}
|
||||
for _, n := range want {
|
||||
byHash := path.Dir(n) + "/by-hash/SHA256/" + sha256Hex(repo.Files[n])
|
||||
if !bytes.Equal(repo.Files[byHash], repo.Files[n]) {
|
||||
t.Errorf("%s not served at %s", n, byHash)
|
||||
}
|
||||
}
|
||||
if len(repo.Files) != 2*len(want) {
|
||||
t.Fatalf("files %v, want each index plus its by-hash copy", slices.Sorted(maps.Keys(repo.Files)))
|
||||
}
|
||||
if n := len(splitStanzas(repo.Files["main/binary-amd64/Packages"])); n != 2 {
|
||||
t.Fatalf("main/binary-amd64 should merge both members, got %d stanzas", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDebIndexFiles(t *testing.T) {
|
||||
rel := parseDebRelease([]byte("Origin: Debian\nMD5Sum:\n a 1 main/binary-amd64/Packages\n a 1 main/binary-amd64/Packages.gz\nSHA256:\n" +
|
||||
" a 1 main/binary-amd64/Packages\n a 1 main/binary-amd64/Packages.xz\n a 1 main/binary-amd64/Packages.gz\n" +
|
||||
" a 1 main/debian-installer/binary-amd64/Packages.xz\n a 1 main/i18n/Translation-en.xz\n a 1 contrib/binary-all/Packages\n"))
|
||||
got := debIndexFiles(rel)
|
||||
want := map[string]string{"main/binary-amd64/": "main/binary-amd64/Packages.xz", "contrib/binary-all/": "contrib/binary-all/Packages"}
|
||||
if !maps.Equal(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
if flat := debIndexFiles(parseDebRelease([]byte("SHA256:\n a 1 Packages\n a 1 Packages.gz\n"))); flat[""] != "Packages.gz" {
|
||||
t.Fatalf("flat repo should fetch Packages.gz, got %v", flat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDebIndexPath(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
in, base, name string
|
||||
ok bool
|
||||
}{
|
||||
{"Release", "", "Release", true},
|
||||
{"Packages.gz", "", "Packages.gz", true},
|
||||
{"dists/trixie/Release", "dists/trixie/", "Release", true},
|
||||
{"dists/trixie/main/binary-amd64/Packages", "dists/trixie/", "main/binary-amd64/Packages", true},
|
||||
{"by-hash/SHA256/abababababababababababababababababababababababababababababababab", "", "by-hash/SHA256/abababababababababababababababababababababababababababababababab", true},
|
||||
{"dists/trixie/main/binary-amd64/by-hash/SHA256/abababababababababababababababababababababababababababababababab", "dists/trixie/", "main/binary-amd64/by-hash/SHA256/abababababababababababababababababababababababababababababababab", true},
|
||||
{"dists/trixie/main/binary-amd64/by-hash/MD5Sum/abababababababababababababababab", "", "", false},
|
||||
{"InRelease", "", "", false},
|
||||
{"Release.gpg", "", "", false},
|
||||
{"dists/trixie/InRelease", "", "", false},
|
||||
{"dists/trixie/main/i18n/Translation-en", "", "", false},
|
||||
{"dists/Release", "", "", false},
|
||||
{"pool/foo.deb", "", "", false},
|
||||
} {
|
||||
base, name, ok := debIndexPath(tc.in)
|
||||
if base != tc.base || name != tc.name || ok != tc.ok {
|
||||
t.Errorf("%s: got (%q, %q, %v), want (%q, %q, %v)", tc.in, base, name, ok, tc.base, tc.name, tc.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
+166
-43
@@ -9,6 +9,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -26,32 +27,51 @@ type Engine struct {
|
||||
proxyEngine *proxy.Engine
|
||||
getRemote func(context.Context, string) (*models.Remote, error)
|
||||
rpmMember func(context.Context, string) (*RPMMember, error)
|
||||
debMember func(ctx context.Context, name, base string) (*DebMember, error)
|
||||
|
||||
mergeTTL time.Duration
|
||||
|
||||
sf singleflight.Group
|
||||
mu sync.Mutex
|
||||
rpm map[string]*rpmGen
|
||||
sf singleflight.Group
|
||||
mu sync.Mutex
|
||||
gens map[string]*repoGen
|
||||
}
|
||||
|
||||
// rpmGen holds a virtual's current merge plus the previous one, so a client
|
||||
// holding the previous repomd can still fetch its content-hashed data files.
|
||||
type rpmGen struct {
|
||||
cur, prev *RPMRepo
|
||||
at time.Time
|
||||
// mergedRepo is a merged index document plus the files it references.
|
||||
type mergedRepo interface {
|
||||
index() []byte
|
||||
files() map[string][]byte
|
||||
}
|
||||
|
||||
func (r *RPMRepo) index() []byte { return r.Repomd }
|
||||
func (r *RPMRepo) files() map[string][]byte { return r.Files }
|
||||
func (r *DebRepo) index() []byte { return r.Release }
|
||||
func (r *DebRepo) files() map[string][]byte { return r.Files }
|
||||
|
||||
// repoGen holds a virtual's current merge plus the previous one, so a client
|
||||
// holding the previous index can still fetch the files it references.
|
||||
type repoGen struct {
|
||||
cur, prev mergedRepo
|
||||
at, used time.Time
|
||||
}
|
||||
|
||||
const rpmMergeTTL = 60 * time.Second
|
||||
|
||||
// maxDebSuites bounds the client-chosen suites cached per deb virtual.
|
||||
const maxDebSuites = 32
|
||||
|
||||
func NewEngine(db *database.DB, proxyEngine *proxy.Engine) *Engine {
|
||||
e := &Engine{db: db, proxyEngine: proxyEngine, getRemote: db.GetRemote, mergeTTL: rpmMergeTTL}
|
||||
e.rpmMember = e.fetchRPMMember
|
||||
e.debMember = e.fetchDebMember
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *Engine) Fetch(ctx context.Context, virt models.Virtual, path string, proxyBaseURL string) ([]byte, string, error) {
|
||||
if virt.PackageType == models.PackageRPM {
|
||||
switch virt.PackageType {
|
||||
case models.PackageRPM:
|
||||
return e.fetchRPM(ctx, virt, path)
|
||||
case models.PackageDeb:
|
||||
return e.fetchDeb(ctx, virt, path)
|
||||
}
|
||||
|
||||
merger, err := GetMerger(virt.PackageType)
|
||||
@@ -170,11 +190,22 @@ var (
|
||||
ErrBadPath = errors.New("invalid path")
|
||||
)
|
||||
|
||||
// MemberRedirect maps an rpm virtual package path (<member>/<href>, as written
|
||||
// by MergeRPM) to the owning member's route. ok is false for any other path;
|
||||
// ErrBadPath means the path is absolute or escapes the member.
|
||||
// MemberRedirect maps an rpm or deb virtual package path (<member>/<href>, as
|
||||
// written by MergeRPM/MergeDeb) to the owning member's route. ok is false for
|
||||
// any other path; ErrBadPath means the path is absolute or escapes the member.
|
||||
func (e *Engine) MemberRedirect(ctx context.Context, virt models.Virtual, path, rawQuery, proxyBaseURL string) (string, bool, error) {
|
||||
if virt.PackageType != models.PackageRPM || strings.HasPrefix(path, "repodata/") {
|
||||
switch virt.PackageType {
|
||||
case models.PackageRPM:
|
||||
if strings.HasPrefix(path, "repodata/") {
|
||||
return "", false, nil
|
||||
}
|
||||
case models.PackageDeb:
|
||||
// apt requests a flat repo's files under its "./" dist verbatim.
|
||||
path = strings.TrimPrefix(path, "./")
|
||||
if strings.HasPrefix(path, "dists/") {
|
||||
return "", false, nil
|
||||
}
|
||||
default:
|
||||
return "", false, nil
|
||||
}
|
||||
name, rest, found := strings.Cut(path, "/")
|
||||
@@ -217,76 +248,93 @@ func (e *Engine) fetchRPM(ctx context.Context, virt models.Virtual, path string)
|
||||
if !strings.HasPrefix(path, "repodata/") {
|
||||
return nil, "", ErrNotFound
|
||||
}
|
||||
key := "rpm\x00" + virt.Name
|
||||
if path != "repodata/repomd.xml" {
|
||||
if body, ok := e.cachedRPMFile(virt.Name, path); ok {
|
||||
if body, ok := e.cachedFile(key, path); ok {
|
||||
return body, "application/gzip", nil
|
||||
}
|
||||
}
|
||||
|
||||
repo, err := e.mergedRPM(ctx, virt)
|
||||
repo, err := e.merged(ctx, key, func(ctx context.Context) (mergedRepo, error) {
|
||||
return e.mergeRPM(ctx, virt)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if path == "repodata/repomd.xml" {
|
||||
return repo.Repomd, "application/xml", nil
|
||||
return repo.index(), "application/xml", nil
|
||||
}
|
||||
if body, ok := repo.Files[path]; ok {
|
||||
if body, ok := repo.files()[path]; ok {
|
||||
return body, "application/gzip", nil
|
||||
}
|
||||
return nil, "", ErrNotFound
|
||||
}
|
||||
|
||||
// mergedRPM returns the virtual's merge, reusing it for mergeTTL and
|
||||
// collapsing concurrent merges of the same virtual into one.
|
||||
// merged returns the merge cached under key, reusing it for mergeTTL and
|
||||
// collapsing concurrent merges of the same key into one.
|
||||
// ponytail: per-replica cache; behind a non-sticky LB a data request landing on
|
||||
// another replica re-merges and 404s if a member changed in between. Move to the
|
||||
// shared redis cache if that shows up.
|
||||
func (e *Engine) mergedRPM(ctx context.Context, virt models.Virtual) (*RPMRepo, error) {
|
||||
func (e *Engine) merged(ctx context.Context, key string, merge func(context.Context) (mergedRepo, error)) (mergedRepo, error) {
|
||||
e.mu.Lock()
|
||||
g := e.rpm[virt.Name]
|
||||
g := e.gens[key]
|
||||
if g != nil && time.Since(g.at) < e.mergeTTL {
|
||||
g.used = time.Now()
|
||||
e.mu.Unlock()
|
||||
return g.cur, nil
|
||||
}
|
||||
e.mu.Unlock()
|
||||
|
||||
v, err, _ := e.sf.Do(virt.Name, func() (any, error) {
|
||||
repo, err := e.mergeRPM(context.WithoutCancel(ctx), virt)
|
||||
v, err, _ := e.sf.Do(key, func() (any, error) {
|
||||
repo, err := merge(context.WithoutCancel(ctx))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if e.rpm == nil {
|
||||
e.rpm = map[string]*rpmGen{}
|
||||
if e.gens == nil {
|
||||
e.gens = map[string]*repoGen{}
|
||||
}
|
||||
g := e.rpm[virt.Name]
|
||||
g := e.gens[key]
|
||||
switch {
|
||||
case g == nil:
|
||||
e.rpm[virt.Name] = &rpmGen{cur: repo, at: time.Now()}
|
||||
case string(g.cur.Repomd) == string(repo.Repomd):
|
||||
g.at = time.Now()
|
||||
e.gens[key] = &repoGen{cur: repo, at: time.Now(), used: time.Now()}
|
||||
case string(g.cur.index()) == string(repo.index()):
|
||||
g.at, g.used = time.Now(), time.Now()
|
||||
repo = g.cur
|
||||
default:
|
||||
g.prev, g.cur, g.at = g.cur, repo, time.Now()
|
||||
g.prev, g.cur, g.at, g.used = g.cur, repo, time.Now(), time.Now()
|
||||
}
|
||||
return repo, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return v.(*RPMRepo), nil
|
||||
return v.(mergedRepo), nil
|
||||
}
|
||||
|
||||
func (e *Engine) mergeRPM(ctx context.Context, virt models.Virtual) (*RPMRepo, error) {
|
||||
members := make([]RPMMember, len(virt.Members))
|
||||
members, err := fetchMembers(virt, func(name string) (*RPMMember, error) { return e.rpmMember(ctx, name) })
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
repo, err := MergeRPM(members)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("merge rpm repodata: %w", err)
|
||||
}
|
||||
return repo, nil
|
||||
}
|
||||
|
||||
// fetchMembers reads every member in parallel, failing if any member fails.
|
||||
func fetchMembers[T any](virt models.Virtual, fetch func(name string) (*T, error)) ([]T, error) {
|
||||
members := make([]T, len(virt.Members))
|
||||
errs := make([]error, len(virt.Members))
|
||||
var wg sync.WaitGroup
|
||||
for i, name := range virt.Members {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
m, err := e.rpmMember(ctx, name)
|
||||
m, err := fetch(name)
|
||||
if err != nil {
|
||||
errs[i] = fmt.Errorf("member %q: %w", name, err)
|
||||
return
|
||||
@@ -299,26 +347,101 @@ func (e *Engine) mergeRPM(ctx context.Context, virt models.Virtual) (*RPMRepo, e
|
||||
if err := errors.Join(errs...); err != nil {
|
||||
return nil, fmt.Errorf("virtual %q: %v", virt.Name, err)
|
||||
}
|
||||
|
||||
repo, err := MergeRPM(members)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("merge rpm repodata: %w", err)
|
||||
}
|
||||
return repo, nil
|
||||
return members, nil
|
||||
}
|
||||
|
||||
func (e *Engine) cachedRPMFile(virt, path string) ([]byte, bool) {
|
||||
func (e *Engine) fetchDeb(ctx context.Context, virt models.Virtual, reqPath string) ([]byte, string, error) {
|
||||
base, name, ok := debIndexPath(strings.TrimPrefix(path.Clean("/"+reqPath), "/"))
|
||||
if !ok {
|
||||
return nil, "", ErrNotFound
|
||||
}
|
||||
key := "deb\x00" + virt.Name + "\x00" + base
|
||||
if name != "Release" {
|
||||
if body, ok := e.cachedFile(key, name); ok {
|
||||
return body, debContentType(name), nil
|
||||
}
|
||||
}
|
||||
|
||||
repo, err := e.merged(ctx, key, func(ctx context.Context) (mergedRepo, error) {
|
||||
members, err := fetchMembers(virt, func(name string) (*DebMember, error) { return e.debMember(ctx, name, base) })
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return MergeDeb(members), nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
e.evictLRU("deb\x00"+virt.Name+"\x00", maxDebSuites)
|
||||
if name == "Release" {
|
||||
return repo.index(), "text/plain", nil
|
||||
}
|
||||
if body, ok := repo.files()[name]; ok {
|
||||
return body, debContentType(name), nil
|
||||
}
|
||||
return nil, "", ErrNotFound
|
||||
}
|
||||
|
||||
func debContentType(name string) string {
|
||||
if strings.HasSuffix(name, ".gz") {
|
||||
return "application/gzip"
|
||||
}
|
||||
return "text/plain"
|
||||
}
|
||||
|
||||
// fetchDebMember reads a member's Release under base and each binary Packages
|
||||
// index it lists.
|
||||
func (e *Engine) fetchDebMember(ctx context.Context, name, base string) (*DebMember, error) {
|
||||
remote, err := e.getRemote(ctx, name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("remote %q: %w", name, err)
|
||||
}
|
||||
rel, err := e.fetchMemberPath(ctx, *remote, base+"Release")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m := &DebMember{RemoteName: name, Release: rel, Packages: map[string][]byte{}}
|
||||
for dir, file := range debIndexFiles(parseDebRelease(rel)) {
|
||||
raw, err := e.fetchMemberPath(ctx, *remote, base+file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if m.Packages[dir], err = decompress(file, raw); err != nil {
|
||||
return nil, fmt.Errorf("decompress %s: %w", file, err)
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// evictLRU drops the least recently used merges under prefix beyond keep.
|
||||
func (e *Engine) evictLRU(prefix string, keep int) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
g := e.rpm[virt]
|
||||
var keys []string
|
||||
for k := range e.gens {
|
||||
if strings.HasPrefix(k, prefix) {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
slices.SortFunc(keys, func(a, b string) int { return e.gens[b].used.Compare(e.gens[a].used) })
|
||||
for _, k := range keys[min(len(keys), keep):] {
|
||||
delete(e.gens, k)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) cachedFile(key, path string) ([]byte, bool) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
g := e.gens[key]
|
||||
if g == nil {
|
||||
return nil, false
|
||||
}
|
||||
for _, r := range []*RPMRepo{g.cur, g.prev} {
|
||||
for _, r := range []mergedRepo{g.cur, g.prev} {
|
||||
if r == nil {
|
||||
continue
|
||||
}
|
||||
if body, ok := r.Files[path]; ok {
|
||||
if body, ok := r.files()[path]; ok {
|
||||
g.used = time.Now()
|
||||
return body, true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
@@ -156,3 +157,213 @@ func TestMemberRedirect(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func debEngine(members map[string]*DebMember) *Engine {
|
||||
e := fakeEngine(nil)
|
||||
e.debMember = func(_ context.Context, name, base string) (*DebMember, error) {
|
||||
if m := members[name]; m != nil && base == "" {
|
||||
return m, nil
|
||||
}
|
||||
return nil, errors.New("upstream down")
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
func debVirt(members ...string) models.Virtual {
|
||||
return models.Virtual{Name: "v", PackageType: models.PackageDeb, Members: members}
|
||||
}
|
||||
|
||||
func TestFetchDebFailsClosed(t *testing.T) {
|
||||
a := flatDebMember("a", "", debStanza("foo", "1", "amd64", "pool/foo.deb"))
|
||||
e := debEngine(map[string]*DebMember{"a": &a})
|
||||
for _, p := range []string{"Release", "./Packages.gz", "dists/trixie/Release"} {
|
||||
if _, _, err := e.Fetch(context.Background(), debVirt("a", "b"), p, ""); err == nil || errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("%s: want upstream error with a member down, got %v", p, err)
|
||||
}
|
||||
}
|
||||
for _, p := range []string{"InRelease", "./Release.gpg", "Packages.xz"} {
|
||||
if _, _, err := e.Fetch(context.Background(), debVirt("a"), p, ""); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("%s: want not found, got %v", p, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchDebRemergesAfterTTL(t *testing.T) {
|
||||
a := flatDebMember("a", "", debStanza("foo", "1", "amd64", "pool/foo_1.deb"))
|
||||
e := debEngine(map[string]*DebMember{"a": &a})
|
||||
virt := debVirt("a")
|
||||
|
||||
rel, _, err := e.Fetch(context.Background(), virt, "./Release", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.Packages[""] = []byte(debStanza("foo", "2", "amd64", "pool/foo_2.deb"))
|
||||
e.mergeTTL = 0
|
||||
newRel, _, err := e.Fetch(context.Background(), virt, "Release", "")
|
||||
if err != nil || string(newRel) == string(rel) {
|
||||
t.Fatalf("Release should reflect the member change (err %v)", err)
|
||||
}
|
||||
pk, ct, err := e.Fetch(context.Background(), virt, "./Packages", "")
|
||||
if err != nil || ct != "text/plain" || !strings.Contains(string(pk), "Version: 2") {
|
||||
t.Fatalf("Packages should be the current merge (ct %q, err %v):\n%s", ct, err, pk)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchDebMergeIsSharedPerSuite(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
e := fakeEngine(nil)
|
||||
e.mergeTTL = time.Minute
|
||||
e.debMember = func(_ context.Context, name, base string) (*DebMember, error) {
|
||||
calls.Add(1)
|
||||
m := flatDebMember(name, "", debStanza("foo", "1", "amd64", "pool/foo.deb"))
|
||||
return &m, nil
|
||||
}
|
||||
for _, p := range []string{"Release", "Packages", "Packages.gz", "./Release", "dists/a/Release", "dists/b/Release"} {
|
||||
if _, _, err := e.Fetch(context.Background(), debVirt("a"), p, ""); err != nil && !errors.Is(err, ErrNotFound) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if n := calls.Load(); n != 3 {
|
||||
t.Fatalf("member fetched %d times, want once per Release dir (3)", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemberRedirectDeb(t *testing.T) {
|
||||
e := fakeEngine(nil)
|
||||
virt := debVirt("gh", "loc")
|
||||
for _, tc := range []struct {
|
||||
path, want string
|
||||
ok bool
|
||||
err error
|
||||
}{
|
||||
{path: "gh/o/r/releases/download/v1/foo_1.0_amd64.deb", ok: true, want: "http://h/api/v1/remote/gh/o/r/releases/download/v1/foo_1.0_amd64.deb"},
|
||||
{path: "./gh/pool/a+b.deb", ok: true, want: "http://h/api/v1/remote/gh/pool/a+b.deb"},
|
||||
{path: "gh/../loc/x.deb", err: ErrBadPath},
|
||||
{path: "dists/trixie/Release"},
|
||||
{path: "Packages"},
|
||||
} {
|
||||
got, ok, err := e.MemberRedirect(context.Background(), virt, tc.path, "", "http://h/")
|
||||
if got != tc.want || ok != tc.ok || !errors.Is(err, tc.err) {
|
||||
t.Errorf("%s: got (%q, %v, %v), want (%q, %v, %v)", tc.path, got, ok, err, tc.want, tc.ok, tc.err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type fakeRepo struct {
|
||||
idx string
|
||||
fs map[string][]byte
|
||||
}
|
||||
|
||||
func (r *fakeRepo) index() []byte { return []byte(r.idx) }
|
||||
func (r *fakeRepo) files() map[string][]byte { return r.fs }
|
||||
|
||||
func genRepo(gen string) func(context.Context) (mergedRepo, error) {
|
||||
return func(context.Context) (mergedRepo, error) {
|
||||
return &fakeRepo{idx: gen, fs: map[string][]byte{gen + ".gz": []byte(gen), "fixed": []byte(gen)}}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergedRetainsPreviousGeneration(t *testing.T) {
|
||||
e := fakeEngine(nil)
|
||||
for _, gen := range []string{"g1", "g2", "g3"} {
|
||||
if _, err := e.merged(context.Background(), "k", genRepo(gen)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for path, want := range map[string]string{"g3.gz": "g3", "g2.gz": "g2", "fixed": "g3"} {
|
||||
if body, ok := e.cachedFile("k", path); !ok || string(body) != want {
|
||||
t.Errorf("%s: got (%q, %v), want %q", path, body, ok, want)
|
||||
}
|
||||
}
|
||||
if _, ok := e.cachedFile("k", "g1.gz"); ok {
|
||||
t.Error("generation two merges back must be dropped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergedReusesWithinTTLAndIsolatesKeys(t *testing.T) {
|
||||
e := fakeEngine(nil)
|
||||
e.mergeTTL = time.Minute
|
||||
for _, step := range []struct{ key, gen, want string }{
|
||||
{"a", "a1", "a1"}, {"b", "b1", "b1"}, {"a", "a2", "a1"},
|
||||
} {
|
||||
repo, err := e.merged(context.Background(), step.key, genRepo(step.gen))
|
||||
if err != nil || string(repo.index()) != step.want {
|
||||
t.Fatalf("%s: got %v (err %v), want %s", step.key, repo, err, step.want)
|
||||
}
|
||||
}
|
||||
if _, ok := e.cachedFile("b", "a1.gz"); ok {
|
||||
t.Error("key b served key a's file")
|
||||
}
|
||||
if _, ok := e.cachedFile("c", "a1.gz"); ok {
|
||||
t.Error("unknown key served a file")
|
||||
}
|
||||
}
|
||||
|
||||
func suiteDebMember(name, version string) *DebMember {
|
||||
return &DebMember{RemoteName: name, Release: []byte("Suite: s\nSHA256:\n x 1 main/binary-amd64/Packages.gz\n"),
|
||||
Packages: map[string][]byte{"main/binary-amd64/": []byte(debStanza("foo", version, "amd64", "pool/foo_"+version+".deb"))}}
|
||||
}
|
||||
|
||||
func TestFetchDebByHashSurvivesRemerge(t *testing.T) {
|
||||
version := "1"
|
||||
e := fakeEngine(nil)
|
||||
e.debMember = func(_ context.Context, name, _ string) (*DebMember, error) { return suiteDebMember(name, version), nil }
|
||||
virt := debVirt("a")
|
||||
fetch := func(p string) []byte {
|
||||
body, _, err := e.Fetch(context.Background(), virt, p, "")
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", p, err)
|
||||
}
|
||||
return body
|
||||
}
|
||||
byHash := func(rel []byte) string {
|
||||
m := regexp.MustCompile(`(?m)^SHA256:\n(?: .*\n)*? ([0-9a-f]{64}) \d+ main/binary-amd64/Packages.gz$`).FindSubmatch(rel)
|
||||
if m == nil {
|
||||
t.Fatalf("no Packages.gz SHA256 in Release:\n%s", rel)
|
||||
}
|
||||
return "dists/trixie/main/binary-amd64/by-hash/SHA256/" + string(m[1])
|
||||
}
|
||||
|
||||
oldRel := fetch("dists/trixie/Release")
|
||||
version, e.mergeTTL = "2", 0
|
||||
if newRel := fetch("dists/trixie/Release"); string(newRel) == string(oldRel) {
|
||||
t.Fatal("Release should reflect the member change")
|
||||
}
|
||||
if got := string(gunzip(t, fetch(byHash(oldRel)))); !strings.Contains(got, "Version: 1\n") {
|
||||
t.Fatalf("by-hash from the previous Release served the wrong index:\n%s", got)
|
||||
}
|
||||
if got := string(fetch("dists/trixie/main/binary-amd64/Packages")); !strings.Contains(got, "Version: 2\n") {
|
||||
t.Fatalf("fixed-name Packages should be the current merge:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchDebBoundsSuitesPerVirtual(t *testing.T) {
|
||||
e := fakeEngine(nil)
|
||||
e.mergeTTL = time.Minute
|
||||
e.debMember = func(_ context.Context, name, _ string) (*DebMember, error) { return suiteDebMember(name, "1"), nil }
|
||||
other := models.Virtual{Name: "other", PackageType: models.PackageDeb, Members: []string{"a"}}
|
||||
if _, _, err := e.Fetch(context.Background(), other, "dists/keep/Release", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := range maxDebSuites + 5 {
|
||||
if _, _, err := e.Fetch(context.Background(), debVirt("a"), fmt.Sprintf("dists/s%d/Release", i), ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
time.Sleep(time.Microsecond)
|
||||
}
|
||||
n := 0
|
||||
for k := range e.gens {
|
||||
if strings.HasPrefix(k, "deb\x00v\x00") {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n != maxDebSuites {
|
||||
t.Fatalf("virtual v caches %d suites, want %d", n, maxDebSuites)
|
||||
}
|
||||
if e.gens["deb\x00v\x00dists/s0/"] != nil || e.gens["deb\x00v\x00dists/s4/"] != nil {
|
||||
t.Fatal("least recently used suites should be evicted first")
|
||||
}
|
||||
if e.gens["deb\x00other\x00dists/keep/"] == nil {
|
||||
t.Fatal("another virtual's suites must not be evicted")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS puppet_metadata (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
repo_name TEXT NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
content_hash TEXT NOT NULL,
|
||||
slug TEXT NOT NULL,
|
||||
version TEXT NOT NULL,
|
||||
metadata_json TEXT NOT NULL,
|
||||
file_size BIGINT NOT NULL DEFAULT 0,
|
||||
file_md5 TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(repo_name, file_path)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_puppet_metadata_repo_slug ON puppet_metadata(repo_name, slug);
|
||||
@@ -218,38 +218,15 @@ sudo apk add --allow-untrusted <package>`,
|
||||
];
|
||||
|
||||
case 'goproxy':
|
||||
return isLocal
|
||||
? [
|
||||
{
|
||||
title: 'Fetch private modules from this proxy',
|
||||
language: 'bash',
|
||||
code: `export GOPROXY=${url}/api/v1/local/${name},https://proxy.golang.org,direct
|
||||
export GONOSUMDB=example.unkin.net
|
||||
go get example.unkin.net/hello@latest`,
|
||||
note: 'Set GONOSUMDB (or GOPRIVATE) to your module prefix: private modules are not in sum.golang.org.',
|
||||
},
|
||||
{
|
||||
title: 'Publish a module version',
|
||||
language: 'bash',
|
||||
code: `# build the zip with golang.org/x/mod/zip (CreateFromDir), or reuse
|
||||
# the .zip/.mod/.info from $(go env GOMODCACHE)/cache/download after go mod download
|
||||
MOD=example.unkin.net/hello VER=v0.1.0
|
||||
for ext in zip mod info; do
|
||||
curl -fsSL --upload-file $VER.$ext \\
|
||||
${url}/api/v2/remotes/${name}/files/$MOD/@v/$VER.$ext
|
||||
done`,
|
||||
note: 'Paths use the case-encoded module path (uppercase as !lower). Zips must hold every file under module@version/ and are rejected otherwise; .info is optional.',
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
title: 'Point the Go module proxy here',
|
||||
language: 'bash',
|
||||
code: `export GOPROXY=${proxy}
|
||||
return [
|
||||
{
|
||||
title: 'Point the Go module proxy here',
|
||||
language: 'bash',
|
||||
code: `export GOPROXY=${proxy}
|
||||
go mod download`,
|
||||
note: 'Append ,direct to fall back to VCS for modules this proxy does not cover.',
|
||||
},
|
||||
];
|
||||
note: 'Append ,direct to fall back to VCS for modules this proxy does not cover.',
|
||||
},
|
||||
];
|
||||
|
||||
case 'cargo':
|
||||
return [
|
||||
@@ -285,21 +262,11 @@ index = "sparse+${proxy}/"`,
|
||||
case 'puppet':
|
||||
return [
|
||||
{
|
||||
title: isLocal ? 'Install a module from this forge' : 'Install a module from the Forge proxy',
|
||||
title: 'Install a module from the Forge proxy',
|
||||
language: 'bash',
|
||||
code: `puppet module install <author>-<module> \\
|
||||
--module_repository ${isLocal ? `${url}/api/v1/local/${name}` : proxy}`,
|
||||
--module_repository ${proxy}`,
|
||||
},
|
||||
...(isLocal
|
||||
? [
|
||||
{
|
||||
title: 'Publish a module (versions are immutable)',
|
||||
language: 'bash',
|
||||
code: `puppet module build && curl -fsSL --upload-file pkg/<author>-<module>-<version>.tar.gz \\
|
||||
${url}/api/v2/remotes/${name}/files/<author>-<module>-<version>.tar.gz`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
case 'deb':
|
||||
|
||||
Reference in New Issue
Block a user