In-house charts have nowhere to publish: a helm local stores uploads but serves no index.yaml, and the helm virtual merger drops every chart field except name/version/urls. - add helm local uploads (`PUT .../files/<name>-<version>.tgz`) recording each Chart.yaml in a new `helm_metadata` table; mismatched filenames stay out of the index - bound chart parsing (50MiB decompressed, 10k entries, 1MiB Chart.yaml) - generate a deterministic `index.yaml` with sha256 digests and path-escaped relative urls - keep all chart fields when merging helm virtual indexes, expanding YAML aliases - add a real-helm e2e-docker test, UI snippet and README section Reviewed-on: #140 Co-authored-by: unkin-agent <unkin-agent@unkin.net> Co-committed-by: unkin-agent <unkin-agent@unkin.net>
This commit was merged in pull request #140.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user