Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 37f8a40f87 | |||
| 1ac8f60196 | |||
| 5280817d80 | |||
| 58be0721c8 | |||
| 7539f93c01 | |||
| dbb1af26d5 | |||
| 18e7f000f7 |
@@ -262,6 +262,22 @@ there's nothing to provision. To bring your own key instead, point
|
||||
`TF_SIGNING_KEY_PASSPHRASE`), which takes precedence over the generated one.
|
||||
`TF_PROVIDER_PROTOCOLS` (default `5.0,6.0`) sets the advertised plugin protocols.
|
||||
|
||||
### Local helm repository
|
||||
|
||||
A local `helm` repo is a real chart repository: `PUT` a packaged chart to
|
||||
`/api/v2/remotes/<repo>/files/<chart>-<version>.tgz` and `index.yaml` is
|
||||
generated from each chart's `Chart.yaml` (with sha256 digests) at
|
||||
`/api/v1/local/<repo>/index.yaml`; a file not named after its `Chart.yaml`
|
||||
name and version is stored but left out of the index. Add the local as a
|
||||
member of a helm virtual to serve it alongside remotes.
|
||||
|
||||
```bash
|
||||
helm package ./mychart
|
||||
curl -fsSL --upload-file mychart-0.1.0.tgz \
|
||||
https://artifactapi.k8s.syd1.au.unkin.net/api/v2/remotes/<repo>/files/mychart-0.1.0.tgz
|
||||
helm repo add <repo> https://artifactapi.k8s.syd1.au.unkin.net/api/v1/local/<repo>
|
||||
```
|
||||
|
||||
### Local puppet forge
|
||||
|
||||
A local `puppet` repo is a Forge: `PUT` a module tarball to
|
||||
|
||||
@@ -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,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()
|
||||
}
|
||||
@@ -1,12 +1,24 @@
|
||||
package helm
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/auth"
|
||||
"git.unkin.net/unkin/artifactapi/internal/provider"
|
||||
"git.unkin.net/unkin/artifactapi/internal/storage"
|
||||
"git.unkin.net/unkin/artifactapi/pkg/models"
|
||||
)
|
||||
|
||||
@@ -56,3 +68,203 @@ func (p *Provider) RewriteResponse(body []byte, remote models.Remote, proxyBaseU
|
||||
func (p *Provider) AuthHeaders(_ context.Context, remote models.Remote) (http.Header, error) {
|
||||
return auth.BasicHeaders(remote), nil
|
||||
}
|
||||
|
||||
// ValidateUpload stores charts flat by filename so index.yaml can reference
|
||||
// them with relative urls, as `helm repo index` does.
|
||||
func (p *Provider) ValidateUpload(filePath string) (storagePath, contentType string, err error) {
|
||||
filename := path.Base(path.Clean("/" + filePath))
|
||||
if !strings.HasSuffix(filename, ".tgz") || filename == ".tgz" {
|
||||
return "", "", fmt.Errorf("file must be a .tgz chart package")
|
||||
}
|
||||
return filename, "application/gzip", nil
|
||||
}
|
||||
|
||||
func (p *Provider) UploadResponse(storagePath, contentHash string, sizeBytes int64) map[string]any {
|
||||
return map[string]any{
|
||||
"filename": storagePath,
|
||||
"content_hash": contentHash,
|
||||
"size_bytes": sizeBytes,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Provider) AfterUpload(ctx context.Context, repoName, storagePath, contentHash string, blobs provider.BlobReader, db provider.MetadataStore) {
|
||||
reader, _, err := blobs.Download(ctx, storage.BlobKey(strings.TrimPrefix(contentHash, "sha256:")))
|
||||
if err != nil {
|
||||
slog.Error("helm metadata: download failed", "repo", repoName, "path", storagePath, "error", err)
|
||||
return
|
||||
}
|
||||
defer func() { _ = reader.Close() }()
|
||||
|
||||
meta, err := parseChart(reader)
|
||||
if err != nil {
|
||||
slog.Error("helm metadata: parse failed", "repo", repoName, "path", storagePath, "error", err)
|
||||
return
|
||||
}
|
||||
if want := meta.Name + "-" + meta.Version + ".tgz"; storagePath != want {
|
||||
slog.Error("helm metadata: filename does not match chart", "repo", repoName, "path", storagePath, "expected", want)
|
||||
return
|
||||
}
|
||||
meta.RepoName = repoName
|
||||
meta.FilePath = storagePath
|
||||
meta.ContentHash = contentHash
|
||||
|
||||
store, ok := db.(provider.HelmMetadataStore)
|
||||
if !ok {
|
||||
slog.Error("helm metadata: store does not support helm metadata", "repo", repoName)
|
||||
return
|
||||
}
|
||||
if err := store.InsertHelmMetadata(ctx, meta); err != nil {
|
||||
slog.Error("helm metadata: insert failed", "repo", repoName, "path", storagePath, "error", err)
|
||||
return
|
||||
}
|
||||
slog.Info("helm metadata: parsed", "repo", repoName, "name", meta.Name, "version", meta.Version)
|
||||
}
|
||||
|
||||
func (p *Provider) AfterDelete(ctx context.Context, repoName, storagePath string, db provider.MetadataDeleter) error {
|
||||
deleter, ok := db.(provider.HelmMetadataDeleter)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return deleter.DeleteHelmMetadata(ctx, repoName, storagePath)
|
||||
}
|
||||
|
||||
func isIndexPath(p string) bool {
|
||||
return strings.TrimPrefix(path.Clean("/"+p), "/") == "index.yaml"
|
||||
}
|
||||
|
||||
func (p *Provider) ServeLocalIndex(w http.ResponseWriter, r *http.Request, files provider.FileStore, repoName, reqPath string) bool {
|
||||
if !isIndexPath(reqPath) {
|
||||
return false
|
||||
}
|
||||
body, err := p.GenerateLocalIndex(r.Context(), files, repoName, reqPath)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return true
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/yaml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(body)
|
||||
return true
|
||||
}
|
||||
|
||||
func (p *Provider) GenerateLocalIndex(ctx context.Context, files provider.FileStore, repoName, reqPath string) ([]byte, error) {
|
||||
if !isIndexPath(reqPath) {
|
||||
return nil, fmt.Errorf("unsupported index path: %q", reqPath)
|
||||
}
|
||||
reader, ok := files.(provider.HelmMetadataReader)
|
||||
if !ok {
|
||||
return nil, errors.New("helm metadata not available")
|
||||
}
|
||||
metas, err := reader.ListHelmMetadataEntries(ctx, repoName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return generateIndex(metas)
|
||||
}
|
||||
|
||||
// generateIndex builds index.yaml from stored Chart.yaml documents; it is
|
||||
// stamped from the newest upload so every replica serves identical bytes.
|
||||
func generateIndex(metas []provider.HelmMetadata) ([]byte, error) {
|
||||
entries := map[string][]map[string]yaml.Node{}
|
||||
var newest time.Time
|
||||
budget := provider.MaxAliasExpandedNodes
|
||||
for _, m := range metas {
|
||||
var raw map[string]yaml.Node
|
||||
if err := yaml.Unmarshal([]byte(m.ChartYAML), &raw); err != nil {
|
||||
return nil, fmt.Errorf("chart %s: %w", m.FilePath, err)
|
||||
}
|
||||
chart, err := provider.ExpandYAMLAliases(raw, &budget)
|
||||
if err != nil {
|
||||
slog.Warn("helm index: dropping chart", "chart", m.Name, "path", m.FilePath, "error", err)
|
||||
continue
|
||||
}
|
||||
chart["name"] = node(m.Name)
|
||||
chart["version"] = node(m.Version)
|
||||
chart["urls"] = node([]string{url.PathEscape(m.FilePath)})
|
||||
chart["digest"] = node(strings.TrimPrefix(m.ContentHash, "sha256:"))
|
||||
chart["created"] = node(m.CreatedAt.UTC().Format(time.RFC3339Nano))
|
||||
entries[m.Name] = append(entries[m.Name], chart)
|
||||
if m.CreatedAt.After(newest) {
|
||||
newest = m.CreatedAt
|
||||
}
|
||||
}
|
||||
return yaml.Marshal(map[string]any{
|
||||
"apiVersion": "v1",
|
||||
"entries": entries,
|
||||
"generated": newest.UTC().Format(time.RFC3339Nano),
|
||||
})
|
||||
}
|
||||
|
||||
func node(v any) yaml.Node {
|
||||
var n yaml.Node
|
||||
_ = n.Encode(v)
|
||||
return n
|
||||
}
|
||||
|
||||
const (
|
||||
maxChartYAML = 1 << 20
|
||||
maxChartBytes = 50 << 20
|
||||
maxChartEntries = 10000
|
||||
)
|
||||
|
||||
var errChartTooLarge = fmt.Errorf("chart archive exceeds %d decompressed bytes", maxChartBytes)
|
||||
|
||||
type capReader struct {
|
||||
r io.Reader
|
||||
n int64
|
||||
}
|
||||
|
||||
func (c *capReader) Read(p []byte) (int, error) {
|
||||
if c.n <= 0 {
|
||||
return 0, errChartTooLarge
|
||||
}
|
||||
if int64(len(p)) > c.n {
|
||||
p = p[:c.n]
|
||||
}
|
||||
n, err := c.r.Read(p)
|
||||
c.n -= int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// parseChart reads the top-level <chart>/Chart.yaml from a packaged chart.
|
||||
func parseChart(r io.Reader) (*provider.HelmMetadata, error) {
|
||||
zr, err := gzip.NewReader(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tr := tar.NewReader(&capReader{r: zr, n: maxChartBytes})
|
||||
for entries := 0; ; entries++ {
|
||||
if entries >= maxChartEntries {
|
||||
return nil, fmt.Errorf("chart archive exceeds %d entries", maxChartEntries)
|
||||
}
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
return nil, errors.New("no Chart.yaml in chart archive")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dir, file := path.Split(path.Clean(hdr.Name))
|
||||
if file != "Chart.yaml" || strings.Count(dir, "/") != 1 {
|
||||
continue
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(tr, maxChartYAML+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(raw) > maxChartYAML {
|
||||
return nil, fmt.Errorf("parse Chart.yaml: exceeds %d bytes", maxChartYAML)
|
||||
}
|
||||
var chart struct {
|
||||
Name string `yaml:"name"`
|
||||
Version string `yaml:"version"`
|
||||
}
|
||||
if err := yaml.Unmarshal(raw, &chart); err != nil {
|
||||
return nil, fmt.Errorf("parse Chart.yaml: %w", err)
|
||||
}
|
||||
if chart.Name == "" || chart.Version == "" {
|
||||
return nil, errors.New("chart metadata missing name or version")
|
||||
}
|
||||
return &provider.HelmMetadata{Name: chart.Name, Version: chart.Version, ChartYAML: string(raw)}, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
package helm
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/provider"
|
||||
)
|
||||
|
||||
func chartTgz(t *testing.T, files map[string]string) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
zw := gzip.NewWriter(&buf)
|
||||
tw := tar.NewWriter(zw)
|
||||
for name, body := range files {
|
||||
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: int64(len(body))}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tw.Write([]byte(body)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
type fakeBlobs struct{ data []byte }
|
||||
|
||||
func (f fakeBlobs) Download(context.Context, string) (io.ReadCloser, int64, error) {
|
||||
return io.NopCloser(bytes.NewReader(f.data)), int64(len(f.data)), nil
|
||||
}
|
||||
|
||||
type fakeStore struct {
|
||||
inserted *provider.HelmMetadata
|
||||
metas []provider.HelmMetadata
|
||||
}
|
||||
|
||||
func (f *fakeStore) InsertRPMMetadata(context.Context, *provider.RPMMetadata) error { return nil }
|
||||
func (f *fakeStore) InsertDebMetadata(context.Context, *provider.DebMetadata) error { return nil }
|
||||
func (f *fakeStore) InsertHelmMetadata(_ context.Context, m *provider.HelmMetadata) error {
|
||||
f.inserted = m
|
||||
return nil
|
||||
}
|
||||
func (f *fakeStore) ListHelmMetadataEntries(context.Context, string) ([]provider.HelmMetadata, error) {
|
||||
return f.metas, nil
|
||||
}
|
||||
func (f *fakeStore) ListFilesByPrefix(context.Context, string, string) ([]provider.FileEntry, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeStore) ListPackages(context.Context, string) ([]string, error) { return nil, nil }
|
||||
|
||||
func TestValidateUpload(t *testing.T) {
|
||||
p := &Provider{}
|
||||
for in, want := range map[string]string{
|
||||
"mychart-0.1.0.tgz": "mychart-0.1.0.tgz",
|
||||
"charts/mychart-0.1.0.tgz": "mychart-0.1.0.tgz",
|
||||
"../../mychart-0.1.0.tgz": "mychart-0.1.0.tgz",
|
||||
} {
|
||||
sp, ct, err := p.ValidateUpload(in)
|
||||
if err != nil || sp != want || ct != "application/gzip" {
|
||||
t.Errorf("ValidateUpload(%q) = %q %q %v", in, sp, ct, err)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"index.yaml", "chart.tar.gz", ".tgz", ""} {
|
||||
if _, _, err := p.ValidateUpload(bad); err == nil {
|
||||
t.Errorf("ValidateUpload(%q) accepted", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAfterUploadParsesTopLevelChart(t *testing.T) {
|
||||
data := chartTgz(t, map[string]string{
|
||||
"mychart/charts/sub/Chart.yaml": "apiVersion: v2\nname: sub\nversion: 9.9.9\n",
|
||||
"mychart/Chart.yaml": "apiVersion: v2\nname: mychart\nversion: 1.0\ndescription: hi\n",
|
||||
"mychart/values.yaml": "a: 1\n",
|
||||
})
|
||||
store := &fakeStore{}
|
||||
(&Provider{}).AfterUpload(context.Background(), "r", "mychart-1.0.tgz", "sha256:ab", fakeBlobs{data}, store)
|
||||
m := store.inserted
|
||||
if m == nil || m.Name != "mychart" || m.Version != "1.0" || m.FilePath != "mychart-1.0.tgz" || m.ContentHash != "sha256:ab" {
|
||||
t.Fatalf("inserted %+v", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAfterUploadRejectsChartWithoutMetadata(t *testing.T) {
|
||||
store := &fakeStore{}
|
||||
data := chartTgz(t, map[string]string{"mychart/Chart.yaml": "apiVersion: v2\nname: mychart\n"})
|
||||
(&Provider{}).AfterUpload(context.Background(), "r", "x.tgz", "sha256:ab", fakeBlobs{data}, store)
|
||||
if store.inserted != nil {
|
||||
t.Fatal("chart without version was recorded")
|
||||
}
|
||||
(&Provider{}).AfterUpload(context.Background(), "r", "x.tgz", "sha256:ab", fakeBlobs{[]byte("not gzip")}, store)
|
||||
if store.inserted != nil {
|
||||
t.Fatal("non-gzip upload was recorded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeLocalIndex(t *testing.T) {
|
||||
t1 := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
t2 := t1.Add(time.Hour)
|
||||
store := &fakeStore{metas: []provider.HelmMetadata{
|
||||
{FilePath: "mychart-1.0.tgz", ContentHash: "sha256:aa", Name: "mychart", Version: "1.0", CreatedAt: t1,
|
||||
ChartYAML: "apiVersion: v2\nname: mychart\nversion: 1.0\nappVersion: \"2\"\ndescription: hi\n"},
|
||||
{FilePath: "other-0.2.0.tgz", ContentHash: "sha256:bb", Name: "other", Version: "0.2.0", CreatedAt: t2,
|
||||
ChartYAML: "apiVersion: v2\nname: other\nversion: 0.2.0\n"},
|
||||
}}
|
||||
p := &Provider{}
|
||||
|
||||
if p.ServeLocalIndex(httptest.NewRecorder(), httptest.NewRequest("GET", "/", nil), store, "r", "mychart-1.0.tgz") {
|
||||
t.Fatal("chart path handled as index")
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
if !p.ServeLocalIndex(rec, httptest.NewRequest("GET", "/", nil), store, "r", "./index.yaml") {
|
||||
t.Fatal("index.yaml not handled")
|
||||
}
|
||||
if rec.Code != 200 || rec.Header().Get("Content-Type") != "text/yaml" {
|
||||
t.Fatalf("code=%d ct=%q", rec.Code, rec.Header().Get("Content-Type"))
|
||||
}
|
||||
|
||||
var idx struct {
|
||||
APIVersion string `yaml:"apiVersion"`
|
||||
Generated string `yaml:"generated"`
|
||||
Entries map[string][]map[string]any
|
||||
}
|
||||
if err := yaml.Unmarshal(rec.Body.Bytes(), &idx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if idx.APIVersion != "v1" || idx.Generated != t2.Format(time.RFC3339Nano) {
|
||||
t.Errorf("header %+v", idx)
|
||||
}
|
||||
e := idx.Entries["mychart"][0]
|
||||
if e["digest"] != "aa" || e["description"] != "hi" || e["appVersion"] != "2" || e["apiVersion"] != "v2" {
|
||||
t.Errorf("entry %v", e)
|
||||
}
|
||||
if urls, _ := e["urls"].([]any); len(urls) != 1 || urls[0] != "mychart-1.0.tgz" {
|
||||
t.Errorf("urls %v", e["urls"])
|
||||
}
|
||||
if e["version"] != "1.0" || e["name"] != "mychart" {
|
||||
t.Errorf("version must stay a string: %#v", e["version"])
|
||||
}
|
||||
if len(idx.Entries["other"]) != 1 {
|
||||
t.Errorf("other entries %v", idx.Entries["other"])
|
||||
}
|
||||
|
||||
again, err := p.GenerateLocalIndex(context.Background(), store, "r", "index.yaml")
|
||||
if err != nil || !bytes.Equal(again, rec.Body.Bytes()) {
|
||||
t.Errorf("index not deterministic: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAfterUploadRejectsFilenameMismatch(t *testing.T) {
|
||||
store := &fakeStore{}
|
||||
data := chartTgz(t, map[string]string{"mychart/Chart.yaml": "apiVersion: v2\nname: mychart\nversion: 1.0.0\n"})
|
||||
(&Provider{}).AfterUpload(context.Background(), "r", "other-1.0.0.tgz", "sha256:ab", fakeBlobs{data}, store)
|
||||
if store.inserted != nil {
|
||||
t.Fatal("chart indexed under a filename that does not match name-version")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseChartLimits(t *testing.T) {
|
||||
chart := "apiVersion: v2\nname: c\nversion: 1.0.0\n"
|
||||
for name, tc := range map[string]struct {
|
||||
files map[string]string
|
||||
want string
|
||||
}{
|
||||
"decompressed": {map[string]string{"c/a.bin": strings.Repeat("\x00", maxChartBytes+1)}, "decompressed bytes"},
|
||||
"chart yaml": {map[string]string{"c/Chart.yaml": chart + "#" + strings.Repeat("x", maxChartYAML)}, "Chart.yaml: exceeds"},
|
||||
} {
|
||||
_, err := parseChart(bytes.NewReader(chartTgz(t, tc.files)))
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Errorf("%s: err = %v, want %q", name, err, tc.want)
|
||||
}
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
zw := gzip.NewWriter(&buf)
|
||||
tw := tar.NewWriter(zw)
|
||||
for i := 0; i <= maxChartEntries; i++ {
|
||||
_ = tw.WriteHeader(&tar.Header{Name: "c/templates/" + strconv.Itoa(i), Mode: 0o644})
|
||||
}
|
||||
_ = tw.WriteHeader(&tar.Header{Name: "c/Chart.yaml", Mode: 0o644, Size: int64(len(chart))})
|
||||
_, _ = tw.Write([]byte(chart))
|
||||
_ = tw.Close()
|
||||
_ = zw.Close()
|
||||
if _, err := parseChart(&buf); err == nil || !strings.Contains(err.Error(), "entries") {
|
||||
t.Errorf("entries: err = %v", err)
|
||||
}
|
||||
|
||||
if m, err := parseChart(bytes.NewReader(chartTgz(t, map[string]string{"c/Chart.yaml": chart}))); err != nil || m.Name != "c" {
|
||||
t.Errorf("valid chart: %v %v", m, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndexEscapesURLs(t *testing.T) {
|
||||
out, err := generateIndex([]provider.HelmMetadata{{FilePath: "my chart-1.0.0.tgz", Name: "my chart", Version: "1.0.0", ChartYAML: "name: x\n"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(out), "my%20chart-1.0.0.tgz") {
|
||||
t.Errorf("url not escaped:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndexExpandsAliasesBeforeOverride(t *testing.T) {
|
||||
body, err := generateIndex([]provider.HelmMetadata{{
|
||||
FilePath: "app-1.0.0.tgz", ContentHash: "sha256:aa", Name: "app", Version: "1.0.0",
|
||||
ChartYAML: "apiVersion: v2\nname: app\nversion: &v 1.0.0\nappVersion: *v\n",
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var idx struct {
|
||||
Entries map[string][]map[string]any
|
||||
}
|
||||
if err := yaml.Unmarshal(body, &idx); err != nil {
|
||||
t.Fatalf("index does not parse: %v\n%s", err, body)
|
||||
}
|
||||
if e := idx.Entries["app"]; len(e) != 1 || e[0]["appVersion"] != "1.0.0" {
|
||||
t.Errorf("entries %v", idx.Entries)
|
||||
}
|
||||
}
|
||||
@@ -248,6 +248,30 @@ func All() map[models.PackageType]Provider {
|
||||
return registry
|
||||
}
|
||||
|
||||
type HelmMetadataStore interface {
|
||||
InsertHelmMetadata(ctx context.Context, meta *HelmMetadata) error
|
||||
}
|
||||
|
||||
type HelmMetadataDeleter interface {
|
||||
DeleteHelmMetadata(ctx context.Context, repoName, filePath string) error
|
||||
}
|
||||
|
||||
type HelmMetadataReader interface {
|
||||
ListHelmMetadataEntries(ctx context.Context, repoName string) ([]HelmMetadata, error)
|
||||
}
|
||||
|
||||
// HelmMetadata is a local chart's Chart.yaml, kept verbatim so index.yaml
|
||||
// entries carry every field helm reads.
|
||||
type HelmMetadata struct {
|
||||
RepoName string
|
||||
FilePath string
|
||||
ContentHash string
|
||||
Name string
|
||||
Version string
|
||||
ChartYAML string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type PuppetMetadataStore interface {
|
||||
InsertPuppetMetadata(ctx context.Context, meta *PuppetMetadata) error
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -2,10 +2,14 @@ package virtual
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/provider"
|
||||
"git.unkin.net/unkin/artifactapi/pkg/models"
|
||||
)
|
||||
|
||||
@@ -16,22 +20,17 @@ func init() {
|
||||
type HelmMerger struct{}
|
||||
|
||||
type helmIndex struct {
|
||||
APIVersion string `yaml:"apiVersion"`
|
||||
Entries map[string][]helmChartVersion `yaml:"entries"`
|
||||
Generated string `yaml:"generated,omitempty"`
|
||||
}
|
||||
|
||||
type helmChartVersion struct {
|
||||
Name string `yaml:"name"`
|
||||
Version string `yaml:"version"`
|
||||
URLs []string `yaml:"urls"`
|
||||
rest map[string]any
|
||||
APIVersion string `yaml:"apiVersion"`
|
||||
Entries map[string][]map[string]yaml.Node `yaml:"entries"`
|
||||
Generated string `yaml:"generated,omitempty"`
|
||||
}
|
||||
|
||||
// MergeIndexes keeps each chart version's fields as raw nodes so digest,
|
||||
// apiVersion and the rest survive the merge untouched; only urls are rewritten.
|
||||
func (m *HelmMerger) MergeIndexes(members []MemberIndex, proxyBaseURL string) ([]byte, error) {
|
||||
merged := &helmIndex{
|
||||
APIVersion: "v1",
|
||||
Entries: make(map[string][]helmChartVersion),
|
||||
Entries: make(map[string][]map[string]yaml.Node),
|
||||
}
|
||||
|
||||
seen := map[string]map[string]bool{}
|
||||
@@ -42,47 +41,38 @@ func (m *HelmMerger) MergeIndexes(members []MemberIndex, proxyBaseURL string) ([
|
||||
continue
|
||||
}
|
||||
|
||||
for chart, versions := range idx.Entries {
|
||||
budget := provider.MaxAliasExpandedNodes
|
||||
for _, chart := range slices.Sorted(maps.Keys(idx.Entries)) {
|
||||
versions := idx.Entries[chart]
|
||||
if seen[chart] == nil {
|
||||
seen[chart] = map[string]bool{}
|
||||
}
|
||||
for _, ver := range versions {
|
||||
key := chart + ":" + ver.Version
|
||||
if seen[chart][ver.Version] {
|
||||
for _, raw := range versions {
|
||||
ver, err := provider.ExpandYAMLAliases(raw, &budget)
|
||||
if err != nil {
|
||||
slog.Warn("helm merge: dropping chart entry", "member", member.RemoteName, "chart", chart, "error", err)
|
||||
continue
|
||||
}
|
||||
seen[chart][ver.Version] = true
|
||||
version := ver["version"].Value
|
||||
if seen[chart][version] {
|
||||
continue
|
||||
}
|
||||
seen[chart][version] = true
|
||||
|
||||
if proxyBaseURL != "" {
|
||||
routePrefix := "remote"
|
||||
if member.RepoType == "local" {
|
||||
routePrefix = "local"
|
||||
if urlsNode, ok := ver["urls"]; ok && proxyBaseURL != "" {
|
||||
var urls []string
|
||||
if err := urlsNode.Decode(&urls); err != nil {
|
||||
continue
|
||||
}
|
||||
baseHost := extractHost(member.BaseURL)
|
||||
|
||||
for i, u := range ver.URLs {
|
||||
if strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://") {
|
||||
if baseHost != "" && extractHost(u) != baseHost {
|
||||
continue
|
||||
}
|
||||
relPath := extractPathRelativeToBase(u, member.BaseURL)
|
||||
ver.URLs[i] = fmt.Sprintf("%s/api/v1/%s/%s/%s",
|
||||
strings.TrimRight(proxyBaseURL, "/"),
|
||||
routePrefix,
|
||||
member.RemoteName,
|
||||
relPath)
|
||||
} else {
|
||||
ver.URLs[i] = fmt.Sprintf("%s/api/v1/%s/%s/%s",
|
||||
strings.TrimRight(proxyBaseURL, "/"),
|
||||
routePrefix,
|
||||
member.RemoteName,
|
||||
u)
|
||||
}
|
||||
rewriteHelmURLs(urls, member, proxyBaseURL)
|
||||
var n yaml.Node
|
||||
if err := n.Encode(urls); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ver["urls"] = n
|
||||
}
|
||||
|
||||
merged.Entries[chart] = append(merged.Entries[chart], ver)
|
||||
_ = key
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,6 +80,34 @@ func (m *HelmMerger) MergeIndexes(members []MemberIndex, proxyBaseURL string) ([
|
||||
return yaml.Marshal(merged)
|
||||
}
|
||||
|
||||
func rewriteHelmURLs(urls []string, member MemberIndex, proxyBaseURL string) {
|
||||
routePrefix := "remote"
|
||||
if member.RepoType == "local" {
|
||||
routePrefix = "local"
|
||||
}
|
||||
baseHost := extractHost(member.BaseURL)
|
||||
|
||||
for i, u := range urls {
|
||||
if strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://") {
|
||||
if baseHost != "" && extractHost(u) != baseHost {
|
||||
continue
|
||||
}
|
||||
relPath := extractPathRelativeToBase(u, member.BaseURL)
|
||||
urls[i] = fmt.Sprintf("%s/api/v1/%s/%s/%s",
|
||||
strings.TrimRight(proxyBaseURL, "/"),
|
||||
routePrefix,
|
||||
member.RemoteName,
|
||||
relPath)
|
||||
} else {
|
||||
urls[i] = fmt.Sprintf("%s/api/v1/%s/%s/%s",
|
||||
strings.TrimRight(proxyBaseURL, "/"),
|
||||
routePrefix,
|
||||
member.RemoteName,
|
||||
u)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func extractHost(rawURL string) string {
|
||||
idx := strings.Index(rawURL, "://")
|
||||
if idx == -1 {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package virtual_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/virtual"
|
||||
)
|
||||
|
||||
@@ -122,3 +125,129 @@ entries:
|
||||
t.Error("expected fallback repo to be excluded for duplicate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelmMerger_PreservesChartFields(t *testing.T) {
|
||||
m := &virtual.HelmMerger{}
|
||||
body := []byte(`apiVersion: v1
|
||||
entries:
|
||||
app:
|
||||
- apiVersion: v2
|
||||
name: app
|
||||
version: "1.0"
|
||||
appVersion: "3.1"
|
||||
description: in-house
|
||||
digest: deadbeef
|
||||
urls:
|
||||
- app-1.0.tgz
|
||||
`)
|
||||
result, err := m.MergeIndexes([]virtual.MemberIndex{{RemoteName: "charts", RepoType: "local", Body: body}}, "https://proxy.example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := string(result)
|
||||
for _, want := range []string{
|
||||
"digest: deadbeef",
|
||||
"apiVersion: v2",
|
||||
`appVersion: "3.1"`,
|
||||
"description: in-house",
|
||||
`version: "1.0"`,
|
||||
"https://proxy.example.com/api/v1/local/charts/app-1.0.tgz",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("merged index missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelmMerger_ExpandsAliasesAcrossDedupe(t *testing.T) {
|
||||
m := &virtual.HelmMerger{}
|
||||
first := []byte(`apiVersion: v1
|
||||
entries:
|
||||
app:
|
||||
- version: "1.0"
|
||||
urls: [app-1.0.tgz]
|
||||
`)
|
||||
second := []byte(`apiVersion: v1
|
||||
entries:
|
||||
app:
|
||||
- version: "1.0"
|
||||
maintainers: &team
|
||||
- name: ops
|
||||
urls: [app-1.0.tgz]
|
||||
- version: "2.0"
|
||||
maintainers: *team
|
||||
urls: [app-2.0.tgz]
|
||||
`)
|
||||
result, err := m.MergeIndexes([]virtual.MemberIndex{
|
||||
{RemoteName: "a", RepoType: "local", Body: first},
|
||||
{RemoteName: "b", RepoType: "local", Body: second},
|
||||
}, "https://proxy.example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var idx struct {
|
||||
Entries map[string][]struct {
|
||||
Version string `yaml:"version"`
|
||||
Maintainers []struct {
|
||||
Name string `yaml:"name"`
|
||||
} `yaml:"maintainers"`
|
||||
} `yaml:"entries"`
|
||||
}
|
||||
if err := yaml.Unmarshal(result, &idx); err != nil {
|
||||
t.Fatalf("merged index does not parse: %v\n%s", err, result)
|
||||
}
|
||||
app := idx.Entries["app"]
|
||||
if len(app) != 2 || app[1].Version != "2.0" || len(app[1].Maintainers) != 1 || app[1].Maintainers[0].Name != "ops" {
|
||||
t.Errorf("alias not expanded: %+v\n%s", app, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelmMerger_DropsAliasBomb(t *testing.T) {
|
||||
m := &virtual.HelmMerger{}
|
||||
body := []byte(`apiVersion: v1
|
||||
entries:
|
||||
app:
|
||||
- version: "1.0"
|
||||
a: &a [x, x, x, x, x, x, x, x, x, x]
|
||||
b: &b [*a, *a, *a, *a, *a, *a, *a, *a, *a, *a]
|
||||
c: &c [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b]
|
||||
d: &d [*c, *c, *c, *c, *c, *c, *c, *c, *c, *c]
|
||||
e: &e [*d, *d, *d, *d, *d, *d, *d, *d, *d, *d]
|
||||
urls: [app-1.0.tgz]
|
||||
`)
|
||||
result, err := m.MergeIndexes([]virtual.MemberIndex{{RemoteName: "a", RepoType: "local", Body: body}}, "https://proxy.example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(result), "app-1.0.tgz") {
|
||||
t.Errorf("alias bomb entry was kept")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelmMerger_LargeIndexWithoutAliasesIsComplete(t *testing.T) {
|
||||
var b strings.Builder
|
||||
b.WriteString("apiVersion: v1\nentries:\n")
|
||||
for c := 0; c < 100; c++ {
|
||||
fmt.Fprintf(&b, " chart%d:\n", c)
|
||||
for v := 0; v < 100; v++ {
|
||||
fmt.Fprintf(&b, " - name: chart%d\n version: \"1.0.%d\"\n description: d\n maintainers: [{name: ops, email: ops@example.com}]\n urls: [chart%d-1.0.%d.tgz]\n", c, v, c, v)
|
||||
}
|
||||
}
|
||||
result, err := (&virtual.HelmMerger{}).MergeIndexes([]virtual.MemberIndex{{RemoteName: "a", RepoType: "local", Body: []byte(b.String())}}, "https://proxy.example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var idx struct {
|
||||
Entries map[string][]map[string]any `yaml:"entries"`
|
||||
}
|
||||
if err := yaml.Unmarshal(result, &idx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
total := 0
|
||||
for _, vs := range idx.Entries {
|
||||
total += len(vs)
|
||||
}
|
||||
if len(idx.Entries) != 100 || total != 10000 {
|
||||
t.Errorf("got %d charts, %d versions; want 100, 10000", len(idx.Entries), total)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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':
|
||||
return [
|
||||
{
|
||||
title: 'Add the Helm repo',
|
||||
title: isLocal ? 'Add the Helm repo (index.yaml auto-generated)' : 'Add the Helm repo',
|
||||
language: 'bash',
|
||||
code: `helm repo add ${name} ${proxy}/
|
||||
code: `helm repo add ${name} ${isLocal ? `${url}/api/v1/local/${name}` : proxy}/
|
||||
helm repo update
|
||||
helm install <release> ${name}/<chart>`,
|
||||
},
|
||||
...(isLocal
|
||||
? [
|
||||
{
|
||||
title: 'Publish a chart',
|
||||
language: 'bash',
|
||||
code: `helm package ./mychart
|
||||
curl -fsSL --upload-file mychart-0.1.0.tgz \\
|
||||
${url}/api/v2/remotes/${name}/files/mychart-0.1.0.tgz`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
case 'alpine':
|
||||
|
||||
Reference in New Issue
Block a user