Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aaa923ffd1 | |||
| 0150b5ebb4 | |||
| 9b91aa39a6 |
@@ -262,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_SIGNING_KEY_PASSPHRASE`), which takes precedence over the generated one.
|
||||||
`TF_PROVIDER_PROTOCOLS` (default `5.0,6.0`) sets the advertised plugin protocols.
|
`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
|
### Local docker registry
|
||||||
|
|
||||||
A local `docker` repo is a real container registry, not a mirror: it serves the
|
A local `docker` repo is a real container registry, not a mirror: it serves the
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ already-running stack.
|
|||||||
helm, pypi, npm, rpm, alpine, puppet, terraform, goproxy, cargo, nuget) 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
|
mock upstream: first fetch `X-Artifact-Source: remote`, second `cache`, bytes
|
||||||
verified against the origin fixture.
|
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).
|
index), rpm (real package + **automatic repodata** generation).
|
||||||
- **Virtual repositories** — pypi simple-index merge and helm `index.yaml` merge
|
- **Virtual repositories** — pypi simple-index merge and helm `index.yaml` merge
|
||||||
across two members.
|
across two members.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
package helm
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"compress/gzip"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
|
||||||
"git.unkin.net/unkin/artifactapi/internal/auth"
|
"git.unkin.net/unkin/artifactapi/internal/auth"
|
||||||
"git.unkin.net/unkin/artifactapi/internal/provider"
|
"git.unkin.net/unkin/artifactapi/internal/provider"
|
||||||
|
"git.unkin.net/unkin/artifactapi/internal/storage"
|
||||||
"git.unkin.net/unkin/artifactapi/pkg/models"
|
"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) {
|
func (p *Provider) AuthHeaders(_ context.Context, remote models.Remote) (http.Header, error) {
|
||||||
return auth.BasicHeaders(remote), nil
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -247,3 +247,53 @@ func Get(t models.PackageType) (Provider, error) {
|
|||||||
func All() map[models.PackageType]Provider {
|
func All() map[models.PackageType]Provider {
|
||||||
return registry
|
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
|
||||||
|
}
|
||||||
+55
-35
@@ -29,15 +29,24 @@ type Engine struct {
|
|||||||
|
|
||||||
mergeTTL time.Duration
|
mergeTTL time.Duration
|
||||||
|
|
||||||
sf singleflight.Group
|
sf singleflight.Group
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
rpm map[string]*rpmGen
|
gens map[string]*repoGen
|
||||||
}
|
}
|
||||||
|
|
||||||
// rpmGen holds a virtual's current merge plus the previous one, so a client
|
// mergedRepo is a merged index document plus the files it references.
|
||||||
// holding the previous repomd can still fetch its content-hashed data files.
|
type mergedRepo interface {
|
||||||
type rpmGen struct {
|
index() []byte
|
||||||
cur, prev *RPMRepo
|
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
|
at time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,54 +226,57 @@ func (e *Engine) fetchRPM(ctx context.Context, virt models.Virtual, path string)
|
|||||||
if !strings.HasPrefix(path, "repodata/") {
|
if !strings.HasPrefix(path, "repodata/") {
|
||||||
return nil, "", ErrNotFound
|
return nil, "", ErrNotFound
|
||||||
}
|
}
|
||||||
|
key := "rpm\x00" + virt.Name
|
||||||
if path != "repodata/repomd.xml" {
|
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
|
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 {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
if path == "repodata/repomd.xml" {
|
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 body, "application/gzip", nil
|
||||||
}
|
}
|
||||||
return nil, "", ErrNotFound
|
return nil, "", ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
// mergedRPM returns the virtual's merge, reusing it for mergeTTL and
|
// merged returns the merge cached under key, reusing it for mergeTTL and
|
||||||
// collapsing concurrent merges of the same virtual into one.
|
// collapsing concurrent merges of the same key into one.
|
||||||
// ponytail: per-replica cache; behind a non-sticky LB a data request landing on
|
// 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
|
// another replica re-merges and 404s if a member changed in between. Move to the
|
||||||
// shared redis cache if that shows up.
|
// 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()
|
e.mu.Lock()
|
||||||
g := e.rpm[virt.Name]
|
g := e.gens[key]
|
||||||
if g != nil && time.Since(g.at) < e.mergeTTL {
|
if g != nil && time.Since(g.at) < e.mergeTTL {
|
||||||
e.mu.Unlock()
|
e.mu.Unlock()
|
||||||
return g.cur, nil
|
return g.cur, nil
|
||||||
}
|
}
|
||||||
e.mu.Unlock()
|
e.mu.Unlock()
|
||||||
|
|
||||||
v, err, _ := e.sf.Do(virt.Name, func() (any, error) {
|
v, err, _ := e.sf.Do(key, func() (any, error) {
|
||||||
repo, err := e.mergeRPM(context.WithoutCancel(ctx), virt)
|
repo, err := merge(context.WithoutCancel(ctx))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
e.mu.Lock()
|
e.mu.Lock()
|
||||||
defer e.mu.Unlock()
|
defer e.mu.Unlock()
|
||||||
if e.rpm == nil {
|
if e.gens == nil {
|
||||||
e.rpm = map[string]*rpmGen{}
|
e.gens = map[string]*repoGen{}
|
||||||
}
|
}
|
||||||
g := e.rpm[virt.Name]
|
g := e.gens[key]
|
||||||
switch {
|
switch {
|
||||||
case g == nil:
|
case g == nil:
|
||||||
e.rpm[virt.Name] = &rpmGen{cur: repo, at: time.Now()}
|
e.gens[key] = &repoGen{cur: repo, at: time.Now()}
|
||||||
case string(g.cur.Repomd) == string(repo.Repomd):
|
case string(g.cur.index()) == string(repo.index()):
|
||||||
g.at = time.Now()
|
g.at = time.Now()
|
||||||
repo = g.cur
|
repo = g.cur
|
||||||
default:
|
default:
|
||||||
@@ -275,18 +287,31 @@ func (e *Engine) mergedRPM(ctx context.Context, virt models.Virtual) (*RPMRepo,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return v.(*RPMRepo), nil
|
return v.(mergedRepo), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) mergeRPM(ctx context.Context, virt models.Virtual) (*RPMRepo, error) {
|
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))
|
errs := make([]error, len(virt.Members))
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
for i, name := range virt.Members {
|
for i, name := range virt.Members {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
m, err := e.rpmMember(ctx, name)
|
m, err := fetch(name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errs[i] = fmt.Errorf("member %q: %w", name, err)
|
errs[i] = fmt.Errorf("member %q: %w", name, err)
|
||||||
return
|
return
|
||||||
@@ -299,26 +324,21 @@ func (e *Engine) mergeRPM(ctx context.Context, virt models.Virtual) (*RPMRepo, e
|
|||||||
if err := errors.Join(errs...); err != nil {
|
if err := errors.Join(errs...); err != nil {
|
||||||
return nil, fmt.Errorf("virtual %q: %v", virt.Name, err)
|
return nil, fmt.Errorf("virtual %q: %v", virt.Name, err)
|
||||||
}
|
}
|
||||||
|
return members, nil
|
||||||
repo, err := MergeRPM(members)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("merge rpm repodata: %w", err)
|
|
||||||
}
|
|
||||||
return repo, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) cachedRPMFile(virt, path string) ([]byte, bool) {
|
func (e *Engine) cachedFile(key, path string) ([]byte, bool) {
|
||||||
e.mu.Lock()
|
e.mu.Lock()
|
||||||
defer e.mu.Unlock()
|
defer e.mu.Unlock()
|
||||||
g := e.rpm[virt]
|
g := e.gens[key]
|
||||||
if g == nil {
|
if g == nil {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
for _, r := range []*RPMRepo{g.cur, g.prev} {
|
for _, r := range []mergedRepo{g.cur, g.prev} {
|
||||||
if r == nil {
|
if r == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if body, ok := r.Files[path]; ok {
|
if body, ok := r.files()[path]; ok {
|
||||||
return body, true
|
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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"maps"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
|
|
||||||
|
"git.unkin.net/unkin/artifactapi/internal/provider"
|
||||||
"git.unkin.net/unkin/artifactapi/pkg/models"
|
"git.unkin.net/unkin/artifactapi/pkg/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -16,22 +20,17 @@ func init() {
|
|||||||
type HelmMerger struct{}
|
type HelmMerger struct{}
|
||||||
|
|
||||||
type helmIndex struct {
|
type helmIndex struct {
|
||||||
APIVersion string `yaml:"apiVersion"`
|
APIVersion string `yaml:"apiVersion"`
|
||||||
Entries map[string][]helmChartVersion `yaml:"entries"`
|
Entries map[string][]map[string]yaml.Node `yaml:"entries"`
|
||||||
Generated string `yaml:"generated,omitempty"`
|
Generated string `yaml:"generated,omitempty"`
|
||||||
}
|
|
||||||
|
|
||||||
type helmChartVersion struct {
|
|
||||||
Name string `yaml:"name"`
|
|
||||||
Version string `yaml:"version"`
|
|
||||||
URLs []string `yaml:"urls"`
|
|
||||||
rest map[string]any
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
func (m *HelmMerger) MergeIndexes(members []MemberIndex, proxyBaseURL string) ([]byte, error) {
|
||||||
merged := &helmIndex{
|
merged := &helmIndex{
|
||||||
APIVersion: "v1",
|
APIVersion: "v1",
|
||||||
Entries: make(map[string][]helmChartVersion),
|
Entries: make(map[string][]map[string]yaml.Node),
|
||||||
}
|
}
|
||||||
|
|
||||||
seen := map[string]map[string]bool{}
|
seen := map[string]map[string]bool{}
|
||||||
@@ -42,47 +41,38 @@ func (m *HelmMerger) MergeIndexes(members []MemberIndex, proxyBaseURL string) ([
|
|||||||
continue
|
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 {
|
if seen[chart] == nil {
|
||||||
seen[chart] = map[string]bool{}
|
seen[chart] = map[string]bool{}
|
||||||
}
|
}
|
||||||
for _, ver := range versions {
|
for _, raw := range versions {
|
||||||
key := chart + ":" + ver.Version
|
ver, err := provider.ExpandYAMLAliases(raw, &budget)
|
||||||
if seen[chart][ver.Version] {
|
if err != nil {
|
||||||
|
slog.Warn("helm merge: dropping chart entry", "member", member.RemoteName, "chart", chart, "error", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seen[chart][ver.Version] = true
|
version := ver["version"].Value
|
||||||
|
if seen[chart][version] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[chart][version] = true
|
||||||
|
|
||||||
if proxyBaseURL != "" {
|
if urlsNode, ok := ver["urls"]; ok && proxyBaseURL != "" {
|
||||||
routePrefix := "remote"
|
var urls []string
|
||||||
if member.RepoType == "local" {
|
if err := urlsNode.Decode(&urls); err != nil {
|
||||||
routePrefix = "local"
|
continue
|
||||||
}
|
}
|
||||||
baseHost := extractHost(member.BaseURL)
|
rewriteHelmURLs(urls, member, proxyBaseURL)
|
||||||
|
var n yaml.Node
|
||||||
for i, u := range ver.URLs {
|
if err := n.Encode(urls); err != nil {
|
||||||
if strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://") {
|
return nil, err
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
ver["urls"] = n
|
||||||
}
|
}
|
||||||
|
|
||||||
merged.Entries[chart] = append(merged.Entries[chart], ver)
|
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)
|
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 {
|
func extractHost(rawURL string) string {
|
||||||
idx := strings.Index(rawURL, "://")
|
idx := strings.Index(rawURL, "://")
|
||||||
if idx == -1 {
|
if idx == -1 {
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package virtual_test
|
package virtual_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
|
||||||
"git.unkin.net/unkin/artifactapi/internal/virtual"
|
"git.unkin.net/unkin/artifactapi/internal/virtual"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -122,3 +125,129 @@ entries:
|
|||||||
t.Error("expected fallback repo to be excluded for duplicate")
|
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);
|
||||||
@@ -167,12 +167,23 @@ docker push ${host}/${name}/myapp:latest`,
|
|||||||
case 'helm':
|
case 'helm':
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
title: 'Add the Helm repo',
|
title: isLocal ? 'Add the Helm repo (index.yaml auto-generated)' : 'Add the Helm repo',
|
||||||
language: 'bash',
|
language: 'bash',
|
||||||
code: `helm repo add ${name} ${proxy}/
|
code: `helm repo add ${name} ${isLocal ? `${url}/api/v1/local/${name}` : proxy}/
|
||||||
helm repo update
|
helm repo update
|
||||||
helm install <release> ${name}/<chart>`,
|
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':
|
case 'alpine':
|
||||||
@@ -262,11 +273,21 @@ index = "sparse+${proxy}/"`,
|
|||||||
case 'puppet':
|
case 'puppet':
|
||||||
return [
|
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',
|
language: 'bash',
|
||||||
code: `puppet module install <author>-<module> \\
|
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':
|
case 'deb':
|
||||||
|
|||||||
Reference in New Issue
Block a user