Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aaa923ffd1 | |||
| 0150b5ebb4 | |||
| 9b91aa39a6 | |||
| bc68eb75fc |
@@ -1,6 +1,6 @@
|
||||
# ArtifactAPI
|
||||
|
||||
Caching proxy for package repositories. Single Go binary, 11 package types, content-addressable storage, managed by Terraform.
|
||||
Caching proxy for package repositories. Single Go binary, 12 package types, content-addressable storage, managed by Terraform.
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -33,6 +33,7 @@ API: `http://localhost:8000` | Frontend: `http://localhost:5173`
|
||||
| `terraform` | `*/versions` | `*/download/*/*` |
|
||||
| `goproxy` | `@v/list`, `@latest` | `.info`, `.mod`, `.zip` |
|
||||
| `cargo` | sparse index files (`config.json` synthesized) | `crates/*/*.crate` |
|
||||
| `nuget` | `index.json` (service index), registration and flat container indexes | `.nupkg`, `.nuspec` |
|
||||
| `github_rpm` | `repodata/*` (synthesized) | `.rpm` (redirected) |
|
||||
|
||||
Providers classify paths automatically. Users only configure what to proxy and TTLs.
|
||||
@@ -261,6 +262,37 @@ 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 helm repository
|
||||
|
||||
A local `helm` repo is a real chart repository: `PUT` a packaged chart to
|
||||
`/api/v2/remotes/<repo>/files/<chart>-<version>.tgz` and `index.yaml` is
|
||||
generated from each chart's `Chart.yaml` (with sha256 digests) at
|
||||
`/api/v1/local/<repo>/index.yaml`; a file not named after its `Chart.yaml`
|
||||
name and version is stored but left out of the index. Add the local as a
|
||||
member of a helm virtual to serve it alongside remotes.
|
||||
|
||||
```bash
|
||||
helm package ./mychart
|
||||
curl -fsSL --upload-file mychart-0.1.0.tgz \
|
||||
https://artifactapi.k8s.syd1.au.unkin.net/api/v2/remotes/<repo>/files/mychart-0.1.0.tgz
|
||||
helm repo add <repo> https://artifactapi.k8s.syd1.au.unkin.net/api/v1/local/<repo>
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
@@ -23,10 +23,11 @@ already-running stack.
|
||||
|
||||
- **Repository lifecycle** — add / change / delete for remote, local and virtual repos.
|
||||
- **Caching** — one immutable artifact per remote package type (generic, docker,
|
||||
helm, pypi, npm, rpm, alpine, puppet, terraform, goproxy, cargo) proxied through the
|
||||
helm, pypi, npm, rpm, alpine, puppet, terraform, goproxy, cargo, nuget) proxied through the
|
||||
mock upstream: first fetch `X-Artifact-Source: remote`, second `cache`, bytes
|
||||
verified against the origin fixture.
|
||||
- **Local uploads** — generic (upload/download), pypi (wheel + generated `simple/`
|
||||
- **Local uploads** — puppet (Forge v3 API via real `r10k` and `puppet module install`),
|
||||
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.
|
||||
|
||||
@@ -30,6 +30,7 @@ func TestCachingPerProvider(t *testing.T) {
|
||||
{"puppet", "puppet-releases/author-mod-1.0.0.tar.gz", "puppet-releases/author-mod-1.0.0.tar.gz"},
|
||||
{"goproxy", "goproxy/example.com/mod/@v/v1.0.0.zip", "goproxy/example.com/mod/@v/v1.0.0.zip"},
|
||||
{"cargo", "crates/mycrate/mycrate-1.0.0.crate", "crates/mycrate/mycrate-1.0.0.crate"},
|
||||
{"nuget", "nuget/e2epkg/1.0.0/e2epkg.1.0.0.nupkg", "nuget/e2epkg/1.0.0/e2epkg.1.0.0.nupkg"},
|
||||
{"terraform", "hashicorp/aws/download/pkg.zip", "v1/providers/hashicorp/aws/download/pkg.zip"},
|
||||
{"docker", "library/testimg/blobs/blobdata", "v2/library/testimg/blobs/blobdata"},
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,71 @@
|
||||
//go:build dockere2e
|
||||
|
||||
package e2edocker
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestLocalHelmRealClient packages a chart with the real helm CLI, publishes it
|
||||
// to a helm local, then drives helm repo add/search/pull/template against the
|
||||
// local and against a helm virtual that has the local as a member.
|
||||
func TestLocalHelmRealClient(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-helm","package_type":"helm","repo_type":"local"}`)
|
||||
defer deleteRepo(t, "local-helm")
|
||||
createVirtual(t, `{"name":"vhelm","package_type":"helm","members":["local-helm"]}`)
|
||||
defer deleteVirtual(t, "vhelm")
|
||||
|
||||
helm := func(script string) string {
|
||||
t.Helper()
|
||||
out, err := exec.Command("docker", "run", "--rm", "--network", network, "--entrypoint", "sh",
|
||||
"alpine/helm:3.16.4", "-c", "set -eu; "+script).Output()
|
||||
if err != nil {
|
||||
stderr := ""
|
||||
if ee, ok := err.(*exec.ExitError); ok {
|
||||
stderr = string(ee.Stderr)
|
||||
}
|
||||
t.Fatalf("helm script failed: %v\n%s%s", err, out, stderr)
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
chart := helm("cd /tmp && helm create e2echart >/dev/null && helm package e2echart >/dev/null && cat e2echart-0.1.0.tgz")
|
||||
uploadFile(t, "local-helm", "e2echart-0.1.0.tgz", []byte(chart), "application/gzip")
|
||||
|
||||
// Chart metadata is recorded asynchronously after upload; poll the index.
|
||||
var body []byte
|
||||
for deadline := time.Now().Add(15 * time.Second); time.Now().Before(deadline); time.Sleep(500 * time.Millisecond) {
|
||||
var resp *http.Response
|
||||
resp, body = doRequest(t, http.MethodGet, api("/api/v1/local/local-helm/index.yaml"), nil, "")
|
||||
if resp.StatusCode == http.StatusOK && strings.Contains(string(body), "e2echart-0.1.0.tgz") {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !strings.Contains(string(body), "e2echart-0.1.0.tgz") || !strings.Contains(string(body), "digest: ") {
|
||||
t.Fatalf("index.yaml never listed the chart:\n%s", body)
|
||||
}
|
||||
|
||||
for _, repoURL := range []string{internal + "/api/v1/local/local-helm", internal + "/api/v1/virtual/vhelm"} {
|
||||
out := helm("helm repo add e2e " + repoURL + " >/dev/null && " +
|
||||
"helm search repo e2e/e2echart && " +
|
||||
"cd /tmp && helm pull e2e/e2echart --version 0.1.0 && " +
|
||||
"helm template rel e2echart-0.1.0.tgz | grep -c 'kind: Deployment'")
|
||||
if !strings.Contains(out, "e2e/e2echart") || !strings.Contains(out, "0.1.0") || !strings.HasSuffix(strings.TrimSpace(out), "1") {
|
||||
t.Fatalf("helm via %s:\n%s", repoURL, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
//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)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -105,13 +106,24 @@ func (h *ProxyHandler) handleProxy(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
defer result.Reader.Close()
|
||||
|
||||
var body io.Reader = result.Reader
|
||||
if sr, ok := prov.(provider.ServeRewriter); ok && prov.Classify(path) == provider.Mutable {
|
||||
raw, err := io.ReadAll(result.Reader)
|
||||
if err != nil {
|
||||
http.Error(w, "bad gateway", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
raw = sr.RewriteServed(raw, *remote, fmt.Sprintf("%s://%s", scheme(r), r.Host))
|
||||
body, result.Size = bytes.NewReader(raw), int64(len(raw))
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", result.ContentType)
|
||||
w.Header().Set("X-Artifact-Source", result.Source)
|
||||
if result.Size > 0 {
|
||||
w.Header().Set("X-Artifact-Size", fmt.Sprintf("%d", result.Size))
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
io.Copy(w, result.Reader)
|
||||
io.Copy(w, body)
|
||||
}
|
||||
|
||||
func (h *ProxyHandler) handleProxyHead(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/provider"
|
||||
)
|
||||
|
||||
func (db *DB) InsertHelmMetadata(ctx context.Context, meta *provider.HelmMetadata) error {
|
||||
_, err := db.Pool.Exec(ctx, `
|
||||
INSERT INTO helm_metadata (repo_name, file_path, content_hash, name, version, chart_yaml)
|
||||
VALUES ($1,$2,$3,$4,$5,$6)
|
||||
ON CONFLICT (repo_name, file_path) DO NOTHING
|
||||
`, meta.RepoName, meta.FilePath, meta.ContentHash, meta.Name, meta.Version, meta.ChartYAML)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) DeleteHelmMetadata(ctx context.Context, repoName, filePath string) error {
|
||||
_, err := db.Pool.Exec(ctx, `DELETE FROM helm_metadata WHERE repo_name = $1 AND file_path = $2`, repoName, filePath)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) ListHelmMetadataEntries(ctx context.Context, repoName string) ([]provider.HelmMetadata, error) {
|
||||
rows, err := db.Pool.Query(ctx, `
|
||||
SELECT repo_name, file_path, content_hash, name, version, chart_yaml, created_at
|
||||
FROM helm_metadata
|
||||
WHERE repo_name = $1
|
||||
ORDER BY name, version, file_path
|
||||
`, repoName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []provider.HelmMetadata
|
||||
for rows.Next() {
|
||||
var m provider.HelmMetadata
|
||||
if err := rows.Scan(&m.RepoName, &m.FilePath, &m.ContentHash, &m.Name, &m.Version, &m.ChartYAML, &m.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, m)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
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,12 +1,24 @@
|
||||
package helm
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/auth"
|
||||
"git.unkin.net/unkin/artifactapi/internal/provider"
|
||||
"git.unkin.net/unkin/artifactapi/internal/storage"
|
||||
"git.unkin.net/unkin/artifactapi/pkg/models"
|
||||
)
|
||||
|
||||
@@ -56,3 +68,203 @@ func (p *Provider) RewriteResponse(body []byte, remote models.Remote, proxyBaseU
|
||||
func (p *Provider) AuthHeaders(_ context.Context, remote models.Remote) (http.Header, error) {
|
||||
return auth.BasicHeaders(remote), nil
|
||||
}
|
||||
|
||||
// ValidateUpload stores charts flat by filename so index.yaml can reference
|
||||
// them with relative urls, as `helm repo index` does.
|
||||
func (p *Provider) ValidateUpload(filePath string) (storagePath, contentType string, err error) {
|
||||
filename := path.Base(path.Clean("/" + filePath))
|
||||
if !strings.HasSuffix(filename, ".tgz") || filename == ".tgz" {
|
||||
return "", "", fmt.Errorf("file must be a .tgz chart package")
|
||||
}
|
||||
return filename, "application/gzip", nil
|
||||
}
|
||||
|
||||
func (p *Provider) UploadResponse(storagePath, contentHash string, sizeBytes int64) map[string]any {
|
||||
return map[string]any{
|
||||
"filename": 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, _, err := blobs.Download(ctx, storage.BlobKey(strings.TrimPrefix(contentHash, "sha256:")))
|
||||
if err != nil {
|
||||
slog.Error("helm metadata: download failed", "repo", repoName, "path", storagePath, "error", err)
|
||||
return
|
||||
}
|
||||
defer func() { _ = reader.Close() }()
|
||||
|
||||
meta, err := parseChart(reader)
|
||||
if err != nil {
|
||||
slog.Error("helm metadata: parse failed", "repo", repoName, "path", storagePath, "error", err)
|
||||
return
|
||||
}
|
||||
if want := meta.Name + "-" + meta.Version + ".tgz"; storagePath != want {
|
||||
slog.Error("helm metadata: filename does not match chart", "repo", repoName, "path", storagePath, "expected", want)
|
||||
return
|
||||
}
|
||||
meta.RepoName = repoName
|
||||
meta.FilePath = storagePath
|
||||
meta.ContentHash = contentHash
|
||||
|
||||
store, ok := db.(provider.HelmMetadataStore)
|
||||
if !ok {
|
||||
slog.Error("helm metadata: store does not support helm metadata", "repo", repoName)
|
||||
return
|
||||
}
|
||||
if err := store.InsertHelmMetadata(ctx, meta); err != nil {
|
||||
slog.Error("helm metadata: insert failed", "repo", repoName, "path", storagePath, "error", err)
|
||||
return
|
||||
}
|
||||
slog.Info("helm metadata: parsed", "repo", repoName, "name", meta.Name, "version", meta.Version)
|
||||
}
|
||||
|
||||
func (p *Provider) AfterDelete(ctx context.Context, repoName, storagePath string, db provider.MetadataDeleter) error {
|
||||
deleter, ok := db.(provider.HelmMetadataDeleter)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return deleter.DeleteHelmMetadata(ctx, repoName, storagePath)
|
||||
}
|
||||
|
||||
func isIndexPath(p string) bool {
|
||||
return strings.TrimPrefix(path.Clean("/"+p), "/") == "index.yaml"
|
||||
}
|
||||
|
||||
func (p *Provider) ServeLocalIndex(w http.ResponseWriter, r *http.Request, files provider.FileStore, repoName, reqPath string) bool {
|
||||
if !isIndexPath(reqPath) {
|
||||
return false
|
||||
}
|
||||
body, err := p.GenerateLocalIndex(r.Context(), files, repoName, reqPath)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return true
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/yaml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(body)
|
||||
return true
|
||||
}
|
||||
|
||||
func (p *Provider) GenerateLocalIndex(ctx context.Context, files provider.FileStore, repoName, reqPath string) ([]byte, error) {
|
||||
if !isIndexPath(reqPath) {
|
||||
return nil, fmt.Errorf("unsupported index path: %q", reqPath)
|
||||
}
|
||||
reader, ok := files.(provider.HelmMetadataReader)
|
||||
if !ok {
|
||||
return nil, errors.New("helm metadata not available")
|
||||
}
|
||||
metas, err := reader.ListHelmMetadataEntries(ctx, repoName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return generateIndex(metas)
|
||||
}
|
||||
|
||||
// generateIndex builds index.yaml from stored Chart.yaml documents; it is
|
||||
// stamped from the newest upload so every replica serves identical bytes.
|
||||
func generateIndex(metas []provider.HelmMetadata) ([]byte, error) {
|
||||
entries := map[string][]map[string]yaml.Node{}
|
||||
var newest time.Time
|
||||
budget := provider.MaxAliasExpandedNodes
|
||||
for _, m := range metas {
|
||||
var raw map[string]yaml.Node
|
||||
if err := yaml.Unmarshal([]byte(m.ChartYAML), &raw); err != nil {
|
||||
return nil, fmt.Errorf("chart %s: %w", m.FilePath, err)
|
||||
}
|
||||
chart, err := provider.ExpandYAMLAliases(raw, &budget)
|
||||
if err != nil {
|
||||
slog.Warn("helm index: dropping chart", "chart", m.Name, "path", m.FilePath, "error", err)
|
||||
continue
|
||||
}
|
||||
chart["name"] = node(m.Name)
|
||||
chart["version"] = node(m.Version)
|
||||
chart["urls"] = node([]string{url.PathEscape(m.FilePath)})
|
||||
chart["digest"] = node(strings.TrimPrefix(m.ContentHash, "sha256:"))
|
||||
chart["created"] = node(m.CreatedAt.UTC().Format(time.RFC3339Nano))
|
||||
entries[m.Name] = append(entries[m.Name], chart)
|
||||
if m.CreatedAt.After(newest) {
|
||||
newest = m.CreatedAt
|
||||
}
|
||||
}
|
||||
return yaml.Marshal(map[string]any{
|
||||
"apiVersion": "v1",
|
||||
"entries": entries,
|
||||
"generated": newest.UTC().Format(time.RFC3339Nano),
|
||||
})
|
||||
}
|
||||
|
||||
func node(v any) yaml.Node {
|
||||
var n yaml.Node
|
||||
_ = n.Encode(v)
|
||||
return n
|
||||
}
|
||||
|
||||
const (
|
||||
maxChartYAML = 1 << 20
|
||||
maxChartBytes = 50 << 20
|
||||
maxChartEntries = 10000
|
||||
)
|
||||
|
||||
var errChartTooLarge = fmt.Errorf("chart archive exceeds %d decompressed bytes", maxChartBytes)
|
||||
|
||||
type capReader struct {
|
||||
r io.Reader
|
||||
n int64
|
||||
}
|
||||
|
||||
func (c *capReader) Read(p []byte) (int, error) {
|
||||
if c.n <= 0 {
|
||||
return 0, errChartTooLarge
|
||||
}
|
||||
if int64(len(p)) > c.n {
|
||||
p = p[:c.n]
|
||||
}
|
||||
n, err := c.r.Read(p)
|
||||
c.n -= int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// parseChart reads the top-level <chart>/Chart.yaml from a packaged chart.
|
||||
func parseChart(r io.Reader) (*provider.HelmMetadata, error) {
|
||||
zr, err := gzip.NewReader(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tr := tar.NewReader(&capReader{r: zr, n: maxChartBytes})
|
||||
for entries := 0; ; entries++ {
|
||||
if entries >= maxChartEntries {
|
||||
return nil, fmt.Errorf("chart archive exceeds %d entries", maxChartEntries)
|
||||
}
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
return nil, errors.New("no Chart.yaml in chart archive")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dir, file := path.Split(path.Clean(hdr.Name))
|
||||
if file != "Chart.yaml" || strings.Count(dir, "/") != 1 {
|
||||
continue
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(tr, maxChartYAML+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(raw) > maxChartYAML {
|
||||
return nil, fmt.Errorf("parse Chart.yaml: exceeds %d bytes", maxChartYAML)
|
||||
}
|
||||
var chart struct {
|
||||
Name string `yaml:"name"`
|
||||
Version string `yaml:"version"`
|
||||
}
|
||||
if err := yaml.Unmarshal(raw, &chart); err != nil {
|
||||
return nil, fmt.Errorf("parse Chart.yaml: %w", err)
|
||||
}
|
||||
if chart.Name == "" || chart.Version == "" {
|
||||
return nil, errors.New("chart metadata missing name or version")
|
||||
}
|
||||
return &provider.HelmMetadata{Name: chart.Name, Version: chart.Version, ChartYAML: string(raw)}, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
package helm
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/provider"
|
||||
)
|
||||
|
||||
func chartTgz(t *testing.T, files map[string]string) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
zw := gzip.NewWriter(&buf)
|
||||
tw := tar.NewWriter(zw)
|
||||
for name, body := range files {
|
||||
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: int64(len(body))}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tw.Write([]byte(body)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
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.HelmMetadata
|
||||
metas []provider.HelmMetadata
|
||||
}
|
||||
|
||||
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) InsertHelmMetadata(_ context.Context, m *provider.HelmMetadata) error {
|
||||
f.inserted = m
|
||||
return nil
|
||||
}
|
||||
func (f *fakeStore) ListHelmMetadataEntries(context.Context, string) ([]provider.HelmMetadata, error) {
|
||||
return f.metas, nil
|
||||
}
|
||||
func (f *fakeStore) ListFilesByPrefix(context.Context, string, string) ([]provider.FileEntry, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeStore) ListPackages(context.Context, string) ([]string, error) { return nil, nil }
|
||||
|
||||
func TestValidateUpload(t *testing.T) {
|
||||
p := &Provider{}
|
||||
for in, want := range map[string]string{
|
||||
"mychart-0.1.0.tgz": "mychart-0.1.0.tgz",
|
||||
"charts/mychart-0.1.0.tgz": "mychart-0.1.0.tgz",
|
||||
"../../mychart-0.1.0.tgz": "mychart-0.1.0.tgz",
|
||||
} {
|
||||
sp, ct, err := p.ValidateUpload(in)
|
||||
if err != nil || sp != want || ct != "application/gzip" {
|
||||
t.Errorf("ValidateUpload(%q) = %q %q %v", in, sp, ct, err)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"index.yaml", "chart.tar.gz", ".tgz", ""} {
|
||||
if _, _, err := p.ValidateUpload(bad); err == nil {
|
||||
t.Errorf("ValidateUpload(%q) accepted", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAfterUploadParsesTopLevelChart(t *testing.T) {
|
||||
data := chartTgz(t, map[string]string{
|
||||
"mychart/charts/sub/Chart.yaml": "apiVersion: v2\nname: sub\nversion: 9.9.9\n",
|
||||
"mychart/Chart.yaml": "apiVersion: v2\nname: mychart\nversion: 1.0\ndescription: hi\n",
|
||||
"mychart/values.yaml": "a: 1\n",
|
||||
})
|
||||
store := &fakeStore{}
|
||||
(&Provider{}).AfterUpload(context.Background(), "r", "mychart-1.0.tgz", "sha256:ab", fakeBlobs{data}, store)
|
||||
m := store.inserted
|
||||
if m == nil || m.Name != "mychart" || m.Version != "1.0" || m.FilePath != "mychart-1.0.tgz" || m.ContentHash != "sha256:ab" {
|
||||
t.Fatalf("inserted %+v", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAfterUploadRejectsChartWithoutMetadata(t *testing.T) {
|
||||
store := &fakeStore{}
|
||||
data := chartTgz(t, map[string]string{"mychart/Chart.yaml": "apiVersion: v2\nname: mychart\n"})
|
||||
(&Provider{}).AfterUpload(context.Background(), "r", "x.tgz", "sha256:ab", fakeBlobs{data}, store)
|
||||
if store.inserted != nil {
|
||||
t.Fatal("chart without version was recorded")
|
||||
}
|
||||
(&Provider{}).AfterUpload(context.Background(), "r", "x.tgz", "sha256:ab", fakeBlobs{[]byte("not gzip")}, store)
|
||||
if store.inserted != nil {
|
||||
t.Fatal("non-gzip upload was recorded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeLocalIndex(t *testing.T) {
|
||||
t1 := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
t2 := t1.Add(time.Hour)
|
||||
store := &fakeStore{metas: []provider.HelmMetadata{
|
||||
{FilePath: "mychart-1.0.tgz", ContentHash: "sha256:aa", Name: "mychart", Version: "1.0", CreatedAt: t1,
|
||||
ChartYAML: "apiVersion: v2\nname: mychart\nversion: 1.0\nappVersion: \"2\"\ndescription: hi\n"},
|
||||
{FilePath: "other-0.2.0.tgz", ContentHash: "sha256:bb", Name: "other", Version: "0.2.0", CreatedAt: t2,
|
||||
ChartYAML: "apiVersion: v2\nname: other\nversion: 0.2.0\n"},
|
||||
}}
|
||||
p := &Provider{}
|
||||
|
||||
if p.ServeLocalIndex(httptest.NewRecorder(), httptest.NewRequest("GET", "/", nil), store, "r", "mychart-1.0.tgz") {
|
||||
t.Fatal("chart path handled as index")
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
if !p.ServeLocalIndex(rec, httptest.NewRequest("GET", "/", nil), store, "r", "./index.yaml") {
|
||||
t.Fatal("index.yaml not handled")
|
||||
}
|
||||
if rec.Code != 200 || rec.Header().Get("Content-Type") != "text/yaml" {
|
||||
t.Fatalf("code=%d ct=%q", rec.Code, rec.Header().Get("Content-Type"))
|
||||
}
|
||||
|
||||
var idx struct {
|
||||
APIVersion string `yaml:"apiVersion"`
|
||||
Generated string `yaml:"generated"`
|
||||
Entries map[string][]map[string]any
|
||||
}
|
||||
if err := yaml.Unmarshal(rec.Body.Bytes(), &idx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if idx.APIVersion != "v1" || idx.Generated != t2.Format(time.RFC3339Nano) {
|
||||
t.Errorf("header %+v", idx)
|
||||
}
|
||||
e := idx.Entries["mychart"][0]
|
||||
if e["digest"] != "aa" || e["description"] != "hi" || e["appVersion"] != "2" || e["apiVersion"] != "v2" {
|
||||
t.Errorf("entry %v", e)
|
||||
}
|
||||
if urls, _ := e["urls"].([]any); len(urls) != 1 || urls[0] != "mychart-1.0.tgz" {
|
||||
t.Errorf("urls %v", e["urls"])
|
||||
}
|
||||
if e["version"] != "1.0" || e["name"] != "mychart" {
|
||||
t.Errorf("version must stay a string: %#v", e["version"])
|
||||
}
|
||||
if len(idx.Entries["other"]) != 1 {
|
||||
t.Errorf("other entries %v", idx.Entries["other"])
|
||||
}
|
||||
|
||||
again, err := p.GenerateLocalIndex(context.Background(), store, "r", "index.yaml")
|
||||
if err != nil || !bytes.Equal(again, rec.Body.Bytes()) {
|
||||
t.Errorf("index not deterministic: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAfterUploadRejectsFilenameMismatch(t *testing.T) {
|
||||
store := &fakeStore{}
|
||||
data := chartTgz(t, map[string]string{"mychart/Chart.yaml": "apiVersion: v2\nname: mychart\nversion: 1.0.0\n"})
|
||||
(&Provider{}).AfterUpload(context.Background(), "r", "other-1.0.0.tgz", "sha256:ab", fakeBlobs{data}, store)
|
||||
if store.inserted != nil {
|
||||
t.Fatal("chart indexed under a filename that does not match name-version")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseChartLimits(t *testing.T) {
|
||||
chart := "apiVersion: v2\nname: c\nversion: 1.0.0\n"
|
||||
for name, tc := range map[string]struct {
|
||||
files map[string]string
|
||||
want string
|
||||
}{
|
||||
"decompressed": {map[string]string{"c/a.bin": strings.Repeat("\x00", maxChartBytes+1)}, "decompressed bytes"},
|
||||
"chart yaml": {map[string]string{"c/Chart.yaml": chart + "#" + strings.Repeat("x", maxChartYAML)}, "Chart.yaml: exceeds"},
|
||||
} {
|
||||
_, err := parseChart(bytes.NewReader(chartTgz(t, tc.files)))
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Errorf("%s: err = %v, want %q", name, err, tc.want)
|
||||
}
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
zw := gzip.NewWriter(&buf)
|
||||
tw := tar.NewWriter(zw)
|
||||
for i := 0; i <= maxChartEntries; i++ {
|
||||
_ = tw.WriteHeader(&tar.Header{Name: "c/templates/" + strconv.Itoa(i), Mode: 0o644})
|
||||
}
|
||||
_ = tw.WriteHeader(&tar.Header{Name: "c/Chart.yaml", Mode: 0o644, Size: int64(len(chart))})
|
||||
_, _ = tw.Write([]byte(chart))
|
||||
_ = tw.Close()
|
||||
_ = zw.Close()
|
||||
if _, err := parseChart(&buf); err == nil || !strings.Contains(err.Error(), "entries") {
|
||||
t.Errorf("entries: err = %v", err)
|
||||
}
|
||||
|
||||
if m, err := parseChart(bytes.NewReader(chartTgz(t, map[string]string{"c/Chart.yaml": chart}))); err != nil || m.Name != "c" {
|
||||
t.Errorf("valid chart: %v %v", m, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndexEscapesURLs(t *testing.T) {
|
||||
out, err := generateIndex([]provider.HelmMetadata{{FilePath: "my chart-1.0.0.tgz", Name: "my chart", Version: "1.0.0", ChartYAML: "name: x\n"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(out), "my%20chart-1.0.0.tgz") {
|
||||
t.Errorf("url not escaped:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndexExpandsAliasesBeforeOverride(t *testing.T) {
|
||||
body, err := generateIndex([]provider.HelmMetadata{{
|
||||
FilePath: "app-1.0.0.tgz", ContentHash: "sha256:aa", Name: "app", Version: "1.0.0",
|
||||
ChartYAML: "apiVersion: v2\nname: app\nversion: &v 1.0.0\nappVersion: *v\n",
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var idx struct {
|
||||
Entries map[string][]map[string]any
|
||||
}
|
||||
if err := yaml.Unmarshal(body, &idx); err != nil {
|
||||
t.Fatalf("index does not parse: %v\n%s", err, body)
|
||||
}
|
||||
if e := idx.Entries["app"]; len(e) != 1 || e[0]["appVersion"] != "1.0.0" {
|
||||
t.Errorf("entries %v", idx.Entries)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// Package nuget proxies a NuGet v3 feed: the service index, registration pages
|
||||
// and flat container indexes are mutable, .nupkg/.nuspec downloads immutable.
|
||||
package nuget
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/auth"
|
||||
"git.unkin.net/unkin/artifactapi/internal/provider"
|
||||
"git.unkin.net/unkin/artifactapi/pkg/models"
|
||||
)
|
||||
|
||||
func init() {
|
||||
provider.Register(&Provider{})
|
||||
}
|
||||
|
||||
// serviceIndex is the path clients use as their source URL; it maps to base_url.
|
||||
const serviceIndex = "index.json"
|
||||
|
||||
type Provider struct{}
|
||||
|
||||
func (p *Provider) Type() models.PackageType { return models.PackageNuGet }
|
||||
|
||||
func (p *Provider) Classify(path string) provider.Mutability {
|
||||
if isPackage(path) {
|
||||
return provider.Immutable
|
||||
}
|
||||
return provider.Mutable
|
||||
}
|
||||
|
||||
func (p *Provider) ContentType(path string) string {
|
||||
switch {
|
||||
case strings.HasSuffix(path, ".nupkg"):
|
||||
return "application/octet-stream"
|
||||
case strings.HasSuffix(path, ".nuspec"):
|
||||
return "application/xml"
|
||||
}
|
||||
return "application/json"
|
||||
}
|
||||
|
||||
// UpstreamURL maps index.json to base_url (the upstream service index) and any
|
||||
// other path to the same path on the base_url's origin, where nuget.org serves
|
||||
// v3-flatcontainer/ and v3/registration*/.
|
||||
func (p *Provider) UpstreamURL(remote models.Remote, path string) string {
|
||||
path = strings.TrimLeft(path, "/")
|
||||
if path == serviceIndex {
|
||||
return remote.BaseURL
|
||||
}
|
||||
return origin(remote.BaseURL) + "/" + path
|
||||
}
|
||||
|
||||
func (p *Provider) RewriteResponse(_ []byte, _ models.Remote, _ string) ([]byte, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// RewriteServed points upstream-origin URLs in cached JSON back at this remote.
|
||||
// It runs per request because the URLs must name the requesting host.
|
||||
func (p *Provider) RewriteServed(body []byte, remote models.Remote, proxyBaseURL string) []byte {
|
||||
from := origin(remote.BaseURL)
|
||||
if from == "" || !json.Valid(body) {
|
||||
return body
|
||||
}
|
||||
to := strings.TrimRight(proxyBaseURL, "/") + "/api/v1/remote/" + remote.Name
|
||||
return []byte(strings.ReplaceAll(string(body), from, to))
|
||||
}
|
||||
|
||||
func (p *Provider) AuthHeaders(_ context.Context, remote models.Remote) (http.Header, error) {
|
||||
return auth.BasicHeaders(remote), nil
|
||||
}
|
||||
|
||||
func origin(baseURL string) string {
|
||||
u, err := url.Parse(baseURL)
|
||||
if err != nil || u.Host == "" {
|
||||
return ""
|
||||
}
|
||||
return u.Scheme + "://" + u.Host
|
||||
}
|
||||
|
||||
func isPackage(path string) bool {
|
||||
return strings.HasSuffix(path, ".nupkg") || strings.HasSuffix(path, ".nuspec")
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package nuget
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/provider"
|
||||
"git.unkin.net/unkin/artifactapi/pkg/models"
|
||||
)
|
||||
|
||||
var _ provider.ServeRewriter = (*Provider)(nil)
|
||||
|
||||
var nugetOrg = models.Remote{Name: "nuget-org", BaseURL: "https://api.nuget.org/v3/index.json"}
|
||||
|
||||
func TestClassify(t *testing.T) {
|
||||
p := &Provider{}
|
||||
tests := []struct {
|
||||
path string
|
||||
want provider.Mutability
|
||||
}{
|
||||
{"v3-flatcontainer/newtonsoft.json/13.0.3/newtonsoft.json.13.0.3.nupkg", provider.Immutable},
|
||||
{"v3-flatcontainer/newtonsoft.json/13.0.3/newtonsoft.json.nuspec", provider.Immutable},
|
||||
{"v3-flatcontainer/newtonsoft.json/index.json", provider.Mutable},
|
||||
{"v3/registration5-gz-semver2/newtonsoft.json/index.json", provider.Mutable},
|
||||
{"index.json", provider.Mutable},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := p.Classify(tt.path); got != tt.want {
|
||||
t.Errorf("Classify(%q) = %v, want %v", tt.path, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpstreamURL(t *testing.T) {
|
||||
p := &Provider{}
|
||||
tests := []struct {
|
||||
path, want string
|
||||
}{
|
||||
{"index.json", "https://api.nuget.org/v3/index.json"},
|
||||
{"/index.json", "https://api.nuget.org/v3/index.json"},
|
||||
{"v3-flatcontainer/serilog/index.json", "https://api.nuget.org/v3-flatcontainer/serilog/index.json"},
|
||||
{"v3/registration5-gz-semver2/serilog/index.json", "https://api.nuget.org/v3/registration5-gz-semver2/serilog/index.json"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := p.UpstreamURL(nugetOrg, tt.path); got != tt.want {
|
||||
t.Errorf("UpstreamURL(%q) = %q, want %q", tt.path, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteServed(t *testing.T) {
|
||||
p := &Provider{}
|
||||
body := `{"resources":[` +
|
||||
`{"@id":"https://api.nuget.org/v3-flatcontainer/","@type":"PackageBaseAddress/3.0.0"},` +
|
||||
`{"@id":"https://azuresearch-usnc.nuget.org/query","@type":"SearchQueryService"}]}`
|
||||
want := `{"resources":[` +
|
||||
`{"@id":"http://aa.example:8000/api/v1/remote/nuget-org/v3-flatcontainer/","@type":"PackageBaseAddress/3.0.0"},` +
|
||||
`{"@id":"https://azuresearch-usnc.nuget.org/query","@type":"SearchQueryService"}]}`
|
||||
if got := string(p.RewriteServed([]byte(body), nugetOrg, "http://aa.example:8000/")); got != want {
|
||||
t.Errorf("RewriteServed =\n%s\nwant\n%s", got, want)
|
||||
}
|
||||
if got := string(p.RewriteServed([]byte(body), models.Remote{Name: "x"}, "http://aa.example")); got != body {
|
||||
t.Error("remote without a parseable base_url must pass the body through")
|
||||
}
|
||||
cert := "\x30\x82https://api.nuget.org"
|
||||
if got := string(p.RewriteServed([]byte(cert), nugetOrg, "http://aa.example")); got != cert {
|
||||
t.Error("non-JSON bodies must pass through untouched")
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,13 @@ type RemoteServer interface {
|
||||
ServeRemote(w http.ResponseWriter, r *http.Request, remote models.Remote, path, proxyBaseURL string, store RemoteMetadataStore) bool
|
||||
}
|
||||
|
||||
// ServeRewriter rewrites a cached mutable remote response per request, for
|
||||
// indexes whose URLs must name the requesting host and so cannot be rewritten
|
||||
// before caching.
|
||||
type ServeRewriter interface {
|
||||
RewriteServed(body []byte, remote models.Remote, proxyBaseURL string) []byte
|
||||
}
|
||||
|
||||
// RemoteMetadataStore is the persistence surface a RemoteServer needs to cache
|
||||
// and read the metadata it derives per upstream asset. *database.DB satisfies it.
|
||||
type RemoteMetadataStore interface {
|
||||
@@ -240,3 +247,53 @@ func Get(t models.PackageType) (Provider, error) {
|
||||
func All() map[models.PackageType]Provider {
|
||||
return registry
|
||||
}
|
||||
|
||||
type HelmMetadataStore interface {
|
||||
InsertHelmMetadata(ctx context.Context, meta *HelmMetadata) error
|
||||
}
|
||||
|
||||
type HelmMetadataDeleter interface {
|
||||
DeleteHelmMetadata(ctx context.Context, repoName, filePath string) error
|
||||
}
|
||||
|
||||
type HelmMetadataReader interface {
|
||||
ListHelmMetadataEntries(ctx context.Context, repoName string) ([]HelmMetadata, error)
|
||||
}
|
||||
|
||||
// HelmMetadata is a local chart's Chart.yaml, kept verbatim so index.yaml
|
||||
// entries carry every field helm reads.
|
||||
type HelmMetadata struct {
|
||||
RepoName string
|
||||
FilePath string
|
||||
ContentHash string
|
||||
Name string
|
||||
Version string
|
||||
ChartYAML string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
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,46 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const MaxAliasExpandedNodes = 100000
|
||||
|
||||
// ExpandYAMLAliases inlines alias targets and drops anchors so a node map
|
||||
// stays valid when copied away from the document that held its anchors;
|
||||
// budget caps nodes reached through aliases against alias bombs.
|
||||
func ExpandYAMLAliases(m map[string]yaml.Node, budget *int) (map[string]yaml.Node, error) {
|
||||
var expand func(n *yaml.Node, viaAlias bool) (*yaml.Node, error)
|
||||
expand = func(n *yaml.Node, viaAlias bool) (*yaml.Node, error) {
|
||||
if viaAlias {
|
||||
*budget--
|
||||
if *budget < 0 {
|
||||
return nil, errors.New("yaml aliases expand to too many nodes")
|
||||
}
|
||||
}
|
||||
if n.Kind == yaml.AliasNode {
|
||||
return expand(n.Alias, true)
|
||||
}
|
||||
c := *n
|
||||
c.Anchor = ""
|
||||
c.Content = make([]*yaml.Node, len(n.Content))
|
||||
for i, ch := range n.Content {
|
||||
var err error
|
||||
if c.Content[i], err = expand(ch, viaAlias); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
out := make(map[string]yaml.Node, len(m))
|
||||
for k, v := range m {
|
||||
e, err := expand(&v, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[k] = *e
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
_ "git.unkin.net/unkin/artifactapi/internal/provider/goproxy"
|
||||
_ "git.unkin.net/unkin/artifactapi/internal/provider/helm"
|
||||
_ "git.unkin.net/unkin/artifactapi/internal/provider/npm"
|
||||
_ "git.unkin.net/unkin/artifactapi/internal/provider/nuget"
|
||||
_ "git.unkin.net/unkin/artifactapi/internal/provider/puppet"
|
||||
_ "git.unkin.net/unkin/artifactapi/internal/provider/pypi"
|
||||
"git.unkin.net/unkin/artifactapi/internal/provider/rpm"
|
||||
|
||||
+55
-35
@@ -29,15 +29,24 @@ type Engine struct {
|
||||
|
||||
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
|
||||
// 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 }
|
||||
|
||||
// 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 time.Time
|
||||
}
|
||||
|
||||
@@ -217,54 +226,57 @@ 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 {
|
||||
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):
|
||||
e.gens[key] = &repoGen{cur: repo, at: time.Now()}
|
||||
case string(g.cur.index()) == string(repo.index()):
|
||||
g.at = time.Now()
|
||||
repo = g.cur
|
||||
default:
|
||||
@@ -275,18 +287,31 @@ func (e *Engine) mergedRPM(ctx context.Context, virt models.Virtual) (*RPMRepo,
|
||||
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 +324,21 @@ 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) cachedFile(key, path string) ([]byte, bool) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
g := e.rpm[virt]
|
||||
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 {
|
||||
return body, true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,3 +156,53 @@ func TestMemberRedirect(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,14 @@ package virtual
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/provider"
|
||||
"git.unkin.net/unkin/artifactapi/pkg/models"
|
||||
)
|
||||
|
||||
@@ -16,22 +20,17 @@ func init() {
|
||||
type HelmMerger struct{}
|
||||
|
||||
type helmIndex struct {
|
||||
APIVersion string `yaml:"apiVersion"`
|
||||
Entries map[string][]helmChartVersion `yaml:"entries"`
|
||||
Generated string `yaml:"generated,omitempty"`
|
||||
}
|
||||
|
||||
type helmChartVersion struct {
|
||||
Name string `yaml:"name"`
|
||||
Version string `yaml:"version"`
|
||||
URLs []string `yaml:"urls"`
|
||||
rest map[string]any
|
||||
APIVersion string `yaml:"apiVersion"`
|
||||
Entries map[string][]map[string]yaml.Node `yaml:"entries"`
|
||||
Generated string `yaml:"generated,omitempty"`
|
||||
}
|
||||
|
||||
// MergeIndexes keeps each chart version's fields as raw nodes so digest,
|
||||
// apiVersion and the rest survive the merge untouched; only urls are rewritten.
|
||||
func (m *HelmMerger) MergeIndexes(members []MemberIndex, proxyBaseURL string) ([]byte, error) {
|
||||
merged := &helmIndex{
|
||||
APIVersion: "v1",
|
||||
Entries: make(map[string][]helmChartVersion),
|
||||
Entries: make(map[string][]map[string]yaml.Node),
|
||||
}
|
||||
|
||||
seen := map[string]map[string]bool{}
|
||||
@@ -42,47 +41,38 @@ func (m *HelmMerger) MergeIndexes(members []MemberIndex, proxyBaseURL string) ([
|
||||
continue
|
||||
}
|
||||
|
||||
for chart, versions := range idx.Entries {
|
||||
budget := provider.MaxAliasExpandedNodes
|
||||
for _, chart := range slices.Sorted(maps.Keys(idx.Entries)) {
|
||||
versions := idx.Entries[chart]
|
||||
if seen[chart] == nil {
|
||||
seen[chart] = map[string]bool{}
|
||||
}
|
||||
for _, ver := range versions {
|
||||
key := chart + ":" + ver.Version
|
||||
if seen[chart][ver.Version] {
|
||||
for _, raw := range versions {
|
||||
ver, err := provider.ExpandYAMLAliases(raw, &budget)
|
||||
if err != nil {
|
||||
slog.Warn("helm merge: dropping chart entry", "member", member.RemoteName, "chart", chart, "error", err)
|
||||
continue
|
||||
}
|
||||
seen[chart][ver.Version] = true
|
||||
version := ver["version"].Value
|
||||
if seen[chart][version] {
|
||||
continue
|
||||
}
|
||||
seen[chart][version] = true
|
||||
|
||||
if proxyBaseURL != "" {
|
||||
routePrefix := "remote"
|
||||
if member.RepoType == "local" {
|
||||
routePrefix = "local"
|
||||
if urlsNode, ok := ver["urls"]; ok && proxyBaseURL != "" {
|
||||
var urls []string
|
||||
if err := urlsNode.Decode(&urls); err != nil {
|
||||
continue
|
||||
}
|
||||
baseHost := extractHost(member.BaseURL)
|
||||
|
||||
for i, u := range ver.URLs {
|
||||
if strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://") {
|
||||
if baseHost != "" && extractHost(u) != baseHost {
|
||||
continue
|
||||
}
|
||||
relPath := extractPathRelativeToBase(u, member.BaseURL)
|
||||
ver.URLs[i] = fmt.Sprintf("%s/api/v1/%s/%s/%s",
|
||||
strings.TrimRight(proxyBaseURL, "/"),
|
||||
routePrefix,
|
||||
member.RemoteName,
|
||||
relPath)
|
||||
} else {
|
||||
ver.URLs[i] = fmt.Sprintf("%s/api/v1/%s/%s/%s",
|
||||
strings.TrimRight(proxyBaseURL, "/"),
|
||||
routePrefix,
|
||||
member.RemoteName,
|
||||
u)
|
||||
}
|
||||
rewriteHelmURLs(urls, member, proxyBaseURL)
|
||||
var n yaml.Node
|
||||
if err := n.Encode(urls); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ver["urls"] = n
|
||||
}
|
||||
|
||||
merged.Entries[chart] = append(merged.Entries[chart], ver)
|
||||
_ = key
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,6 +80,34 @@ func (m *HelmMerger) MergeIndexes(members []MemberIndex, proxyBaseURL string) ([
|
||||
return yaml.Marshal(merged)
|
||||
}
|
||||
|
||||
func rewriteHelmURLs(urls []string, member MemberIndex, proxyBaseURL string) {
|
||||
routePrefix := "remote"
|
||||
if member.RepoType == "local" {
|
||||
routePrefix = "local"
|
||||
}
|
||||
baseHost := extractHost(member.BaseURL)
|
||||
|
||||
for i, u := range urls {
|
||||
if strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://") {
|
||||
if baseHost != "" && extractHost(u) != baseHost {
|
||||
continue
|
||||
}
|
||||
relPath := extractPathRelativeToBase(u, member.BaseURL)
|
||||
urls[i] = fmt.Sprintf("%s/api/v1/%s/%s/%s",
|
||||
strings.TrimRight(proxyBaseURL, "/"),
|
||||
routePrefix,
|
||||
member.RemoteName,
|
||||
relPath)
|
||||
} else {
|
||||
urls[i] = fmt.Sprintf("%s/api/v1/%s/%s/%s",
|
||||
strings.TrimRight(proxyBaseURL, "/"),
|
||||
routePrefix,
|
||||
member.RemoteName,
|
||||
u)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func extractHost(rawURL string) string {
|
||||
idx := strings.Index(rawURL, "://")
|
||||
if idx == -1 {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package virtual_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/virtual"
|
||||
)
|
||||
|
||||
@@ -122,3 +125,129 @@ entries:
|
||||
t.Error("expected fallback repo to be excluded for duplicate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelmMerger_PreservesChartFields(t *testing.T) {
|
||||
m := &virtual.HelmMerger{}
|
||||
body := []byte(`apiVersion: v1
|
||||
entries:
|
||||
app:
|
||||
- apiVersion: v2
|
||||
name: app
|
||||
version: "1.0"
|
||||
appVersion: "3.1"
|
||||
description: in-house
|
||||
digest: deadbeef
|
||||
urls:
|
||||
- app-1.0.tgz
|
||||
`)
|
||||
result, err := m.MergeIndexes([]virtual.MemberIndex{{RemoteName: "charts", RepoType: "local", Body: body}}, "https://proxy.example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := string(result)
|
||||
for _, want := range []string{
|
||||
"digest: deadbeef",
|
||||
"apiVersion: v2",
|
||||
`appVersion: "3.1"`,
|
||||
"description: in-house",
|
||||
`version: "1.0"`,
|
||||
"https://proxy.example.com/api/v1/local/charts/app-1.0.tgz",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("merged index missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelmMerger_ExpandsAliasesAcrossDedupe(t *testing.T) {
|
||||
m := &virtual.HelmMerger{}
|
||||
first := []byte(`apiVersion: v1
|
||||
entries:
|
||||
app:
|
||||
- version: "1.0"
|
||||
urls: [app-1.0.tgz]
|
||||
`)
|
||||
second := []byte(`apiVersion: v1
|
||||
entries:
|
||||
app:
|
||||
- version: "1.0"
|
||||
maintainers: &team
|
||||
- name: ops
|
||||
urls: [app-1.0.tgz]
|
||||
- version: "2.0"
|
||||
maintainers: *team
|
||||
urls: [app-2.0.tgz]
|
||||
`)
|
||||
result, err := m.MergeIndexes([]virtual.MemberIndex{
|
||||
{RemoteName: "a", RepoType: "local", Body: first},
|
||||
{RemoteName: "b", RepoType: "local", Body: second},
|
||||
}, "https://proxy.example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var idx struct {
|
||||
Entries map[string][]struct {
|
||||
Version string `yaml:"version"`
|
||||
Maintainers []struct {
|
||||
Name string `yaml:"name"`
|
||||
} `yaml:"maintainers"`
|
||||
} `yaml:"entries"`
|
||||
}
|
||||
if err := yaml.Unmarshal(result, &idx); err != nil {
|
||||
t.Fatalf("merged index does not parse: %v\n%s", err, result)
|
||||
}
|
||||
app := idx.Entries["app"]
|
||||
if len(app) != 2 || app[1].Version != "2.0" || len(app[1].Maintainers) != 1 || app[1].Maintainers[0].Name != "ops" {
|
||||
t.Errorf("alias not expanded: %+v\n%s", app, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelmMerger_DropsAliasBomb(t *testing.T) {
|
||||
m := &virtual.HelmMerger{}
|
||||
body := []byte(`apiVersion: v1
|
||||
entries:
|
||||
app:
|
||||
- version: "1.0"
|
||||
a: &a [x, x, x, x, x, x, x, x, x, x]
|
||||
b: &b [*a, *a, *a, *a, *a, *a, *a, *a, *a, *a]
|
||||
c: &c [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b]
|
||||
d: &d [*c, *c, *c, *c, *c, *c, *c, *c, *c, *c]
|
||||
e: &e [*d, *d, *d, *d, *d, *d, *d, *d, *d, *d]
|
||||
urls: [app-1.0.tgz]
|
||||
`)
|
||||
result, err := m.MergeIndexes([]virtual.MemberIndex{{RemoteName: "a", RepoType: "local", Body: body}}, "https://proxy.example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(result), "app-1.0.tgz") {
|
||||
t.Errorf("alias bomb entry was kept")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelmMerger_LargeIndexWithoutAliasesIsComplete(t *testing.T) {
|
||||
var b strings.Builder
|
||||
b.WriteString("apiVersion: v1\nentries:\n")
|
||||
for c := 0; c < 100; c++ {
|
||||
fmt.Fprintf(&b, " chart%d:\n", c)
|
||||
for v := 0; v < 100; v++ {
|
||||
fmt.Fprintf(&b, " - name: chart%d\n version: \"1.0.%d\"\n description: d\n maintainers: [{name: ops, email: ops@example.com}]\n urls: [chart%d-1.0.%d.tgz]\n", c, v, c, v)
|
||||
}
|
||||
}
|
||||
result, err := (&virtual.HelmMerger{}).MergeIndexes([]virtual.MemberIndex{{RemoteName: "a", RepoType: "local", Body: []byte(b.String())}}, "https://proxy.example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var idx struct {
|
||||
Entries map[string][]map[string]any `yaml:"entries"`
|
||||
}
|
||||
if err := yaml.Unmarshal(result, &idx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
total := 0
|
||||
for _, vs := range idx.Entries {
|
||||
total += len(vs)
|
||||
}
|
||||
if len(idx.Entries) != 100 || total != 10000 {
|
||||
t.Errorf("got %d charts, %d versions; want 100, 10000", len(idx.Entries), total)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
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);
|
||||
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE IF NOT EXISTS helm_metadata (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
repo_name TEXT NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
content_hash TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
version TEXT NOT NULL,
|
||||
chart_yaml TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(repo_name, file_path)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_helm_metadata_repo ON helm_metadata(repo_name);
|
||||
@@ -17,6 +17,7 @@ const (
|
||||
PackageTerraform PackageType = "terraform"
|
||||
PackageGoProxy PackageType = "goproxy"
|
||||
PackageCargo PackageType = "cargo"
|
||||
PackageNuGet PackageType = "nuget"
|
||||
PackageGitHubRPM PackageType = "github_rpm"
|
||||
PackageGitHubDeb PackageType = "github_deb"
|
||||
PackageGitHubAlpine PackageType = "github_alpine"
|
||||
@@ -35,6 +36,7 @@ var validPackageTypes = map[PackageType]bool{
|
||||
PackageTerraform: true,
|
||||
PackageGoProxy: true,
|
||||
PackageCargo: true,
|
||||
PackageNuGet: true,
|
||||
PackageGitHubRPM: true,
|
||||
PackageGitHubDeb: true,
|
||||
PackageGitHubAlpine: true,
|
||||
|
||||
@@ -19,6 +19,7 @@ func TestPackageTypeValid(t *testing.T) {
|
||||
models.PackageTerraform,
|
||||
models.PackageGoProxy,
|
||||
models.PackageCargo,
|
||||
models.PackageNuGet,
|
||||
models.PackageGitHubRPM,
|
||||
models.PackageGitHubDeb,
|
||||
models.PackageGitHubAlpine,
|
||||
|
||||
@@ -167,12 +167,23 @@ docker push ${host}/${name}/myapp:latest`,
|
||||
case 'helm':
|
||||
return [
|
||||
{
|
||||
title: 'Add the Helm repo',
|
||||
title: isLocal ? 'Add the Helm repo (index.yaml auto-generated)' : 'Add the Helm repo',
|
||||
language: 'bash',
|
||||
code: `helm repo add ${name} ${proxy}/
|
||||
code: `helm repo add ${name} ${isLocal ? `${url}/api/v1/local/${name}` : proxy}/
|
||||
helm repo update
|
||||
helm install <release> ${name}/<chart>`,
|
||||
},
|
||||
...(isLocal
|
||||
? [
|
||||
{
|
||||
title: 'Publish a chart',
|
||||
language: 'bash',
|
||||
code: `helm package ./mychart
|
||||
curl -fsSL --upload-file mychart-0.1.0.tgz \\
|
||||
${url}/api/v2/remotes/${name}/files/mychart-0.1.0.tgz`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
case 'alpine':
|
||||
@@ -243,14 +254,40 @@ index = "sparse+${proxy}/"`,
|
||||
},
|
||||
];
|
||||
|
||||
case 'nuget':
|
||||
return [
|
||||
{
|
||||
title: 'Use this feed instead of nuget.org',
|
||||
language: 'xml',
|
||||
code: `<!-- nuget.config -->
|
||||
<configuration>
|
||||
<packageSources>
|
||||
<clear />
|
||||
<add key="${name}" value="${proxy}/index.json" protocolVersion="3" />
|
||||
</packageSources>
|
||||
</configuration>`,
|
||||
note: 'Serve artifactapi over HTTPS: NuGet rejects the repository signatures resource over plain HTTP.',
|
||||
},
|
||||
];
|
||||
|
||||
case 'puppet':
|
||||
return [
|
||||
{
|
||||
title: 'Install a module from the Forge proxy',
|
||||
title: isLocal ? 'Install a module from this forge' : 'Install a module from the Forge proxy',
|
||||
language: 'bash',
|
||||
code: `puppet module install <author>-<module> \\
|
||||
--module_repository ${proxy}`,
|
||||
--module_repository ${isLocal ? `${url}/api/v1/local/${name}` : 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':
|
||||
|
||||
@@ -18,6 +18,7 @@ const typeColors: Record<string, 'blue' | 'green' | 'yellow' | 'red' | 'default'
|
||||
terraform: 'blue',
|
||||
goproxy: 'green',
|
||||
cargo: 'yellow',
|
||||
nuget: 'blue',
|
||||
};
|
||||
|
||||
export function Locals() {
|
||||
|
||||
@@ -18,6 +18,7 @@ const typeColors: Record<string, 'blue' | 'green' | 'yellow' | 'red' | 'default'
|
||||
terraform: 'blue',
|
||||
goproxy: 'green',
|
||||
cargo: 'yellow',
|
||||
nuget: 'blue',
|
||||
};
|
||||
|
||||
export function Remotes() {
|
||||
|
||||
Reference in New Issue
Block a user