diff --git a/pkg/repo/v1/index.go b/pkg/repo/v1/index.go index 57e4c081f..693635f40 100644 --- a/pkg/repo/v1/index.go +++ b/pkg/repo/v1/index.go @@ -1,435 +1,494 @@ -/* -Copyright The Helm Authors. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package repo - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "log/slog" - "os" - "path" - "path/filepath" - "slices" - "sort" - "strings" - "time" - - "github.com/Masterminds/semver/v3" - "sigs.k8s.io/yaml" - - "helm.sh/helm/v4/internal/fileutil" - "helm.sh/helm/v4/internal/urlutil" - chart "helm.sh/helm/v4/pkg/chart/v2" - "helm.sh/helm/v4/pkg/chart/v2/loader" - "helm.sh/helm/v4/pkg/provenance" -) - -// APIVersionV1 is the v1 API version for index and repository files. -const APIVersionV1 = "v1" - -var ( - // ErrNoAPIVersion indicates that an API version was not specified. - ErrNoAPIVersion = errors.New("no API version specified") - // ErrNoChartVersion indicates that a chart with the given version is not found. - ErrNoChartVersion = errors.New("no chart version found") - // ErrNoChartName indicates that a chart with the given name is not found. - ErrNoChartName = errors.New("no chart name found") - // ErrEmptyIndexYaml indicates that the content of index.yaml is empty. - ErrEmptyIndexYaml = errors.New("empty index.yaml file") -) - -// ChartVersions is a list of versioned chart references. -// Implements a sorter on Version. -type ChartVersions []*ChartVersion - -// Len returns the length. -func (c ChartVersions) Len() int { return len(c) } - -// Swap swaps the position of two items in the versions slice. -func (c ChartVersions) Swap(i, j int) { c[i], c[j] = c[j], c[i] } - -// Less returns true if the version of entry a is less than the version of entry b. -func (c ChartVersions) Less(a, b int) bool { - // Failed parse pushes to the back. - i, err := semver.NewVersion(c[a].Version) - if err != nil { - return true - } - j, err := semver.NewVersion(c[b].Version) - if err != nil { - return false - } - return i.LessThan(j) -} - -// IndexFile represents the index file in a chart repository -type IndexFile struct { - // This is used ONLY for validation against chartmuseum's index files and is discarded after validation. - ServerInfo map[string]any `json:"serverInfo,omitempty"` - APIVersion string `json:"apiVersion"` - Generated time.Time `json:"generated"` - Entries map[string]ChartVersions `json:"entries"` - PublicKeys []string `json:"publicKeys,omitempty"` - - // Annotations are additional mappings uninterpreted by Helm. They are made available for - // other applications to add information to the index file. - Annotations map[string]string `json:"annotations,omitempty"` -} - -// NewIndexFile initializes an index. -func NewIndexFile() *IndexFile { - return &IndexFile{ - APIVersion: APIVersionV1, - Generated: time.Now(), - Entries: map[string]ChartVersions{}, - PublicKeys: []string{}, - } -} - -// LoadIndexFile takes a file at the given path and returns an IndexFile object -func LoadIndexFile(path string) (*IndexFile, error) { - b, err := os.ReadFile(path) - if err != nil { - return nil, err - } - i, err := loadIndex(b, path) - if err != nil { - return nil, fmt.Errorf("error loading %s: %w", path, err) - } - return i, nil -} - -// MustAdd adds a file to the index -// This can leave the index in an unsorted state -func (i IndexFile) MustAdd(md *chart.Metadata, filename, baseURL, digest string) error { - if i.Entries == nil { - return errors.New("entries not initialized") - } - - if md.APIVersion == "" { - md.APIVersion = chart.APIVersionV1 - } - if err := md.Validate(); err != nil { - return fmt.Errorf("validate failed for %s: %w", filename, err) - } - - u := filename - if baseURL != "" { - _, file := filepath.Split(filename) - var err error - u, err = urlutil.URLJoin(baseURL, file) - if err != nil { - u = path.Join(baseURL, file) - } - } - cr := &ChartVersion{ - URLs: []string{u}, - Metadata: md, - Digest: digest, - Created: time.Now(), - } - ee := i.Entries[md.Name] - i.Entries[md.Name] = append(ee, cr) - return nil -} - -// Add adds a file to the index and logs an error. -// -// Deprecated: Use index.MustAdd instead. -func (i IndexFile) Add(md *chart.Metadata, filename, baseURL, digest string) { - if err := i.MustAdd(md, filename, baseURL, digest); err != nil { - slog.Error("skipping loading invalid entry for chart", "name", md.Name, "version", md.Version, "file", filename, "error", err) - } -} - -// Has returns true if the index has an entry for a chart with the given name and exact version. -func (i IndexFile) Has(name, version string) bool { - _, err := i.Get(name, version) - return err == nil -} - -// SortEntries sorts the entries by version in descending order. -// -// In canonical form, the individual version records should be sorted so that -// the most recent release for every version is in the 0th slot in the -// Entries.ChartVersions array. That way, tooling can predict the newest -// version without needing to parse SemVers. -func (i IndexFile) SortEntries() { - for _, versions := range i.Entries { - sort.Sort(sort.Reverse(versions)) - } -} - -// isVersionRange checks if the version string is a range constraint (e.g., "^1", "~1.10") -// rather than an exact version (e.g., "1.10.0"). -func isVersionRange(version string) bool { - if strings.ContainsAny(version, "^~<>=!*") || strings.Contains(version, "||") || strings.Contains(version, " - ") { - return true - } - core := version - if idx := strings.IndexAny(version, "-+"); idx != -1 { - core = version[:idx] - } - return strings.ContainsAny(core, "xX") -} - -// Get returns the ChartVersion for the given name. -// -// If version is empty, this will return the chart with the latest stable version, -// prerelease versions will be skipped. -func (i IndexFile) Get(name, version string) (*ChartVersion, error) { - vs, ok := i.Entries[name] - if !ok { - return nil, ErrNoChartName - } - if len(vs) == 0 { - return nil, ErrNoChartVersion - } - - var constraint *semver.Constraints - if version == "" { - constraint, _ = semver.NewConstraint("*") - } else { - var err error - constraint, err = semver.NewConstraint(version) - if err != nil { - return nil, err - } - } - - // when customer inputs specific version, check whether there's an exact match first - if len(version) != 0 { - for _, ver := range vs { - if version == ver.Version { - return ver, nil - } - } - } - - for _, ver := range vs { - test, err := semver.NewVersion(ver.Version) - if err != nil { - continue - } - - if constraint.Check(test) { - if len(version) != 0 && !isVersionRange(version) { - slog.Warn("unable to find exact version requested; falling back to closest available version", "chart", name, "requested", version, "selected", ver.Version) - } else if len(version) != 0 && isVersionRange(version) { - slog.Debug("selected version matching constraint", "chart", name, "constraint", version, "selected", ver.Version) - } - return ver, nil - } - } - return nil, fmt.Errorf("no chart version found for %s-%s", name, version) -} - -// WriteFile writes an index file to the given destination path. -// -// The mode on the file is set to 'mode'. -func (i IndexFile) WriteFile(dest string, mode os.FileMode) error { - b, err := yaml.Marshal(i) - if err != nil { - return err - } - return fileutil.AtomicWriteFile(dest, bytes.NewReader(b), mode) -} - -// WriteJSONFile writes an index file in JSON format to the given destination -// path. -// -// The mode on the file is set to 'mode'. -func (i IndexFile) WriteJSONFile(dest string, mode os.FileMode) error { - b, err := json.MarshalIndent(i, "", " ") - if err != nil { - return err - } - return fileutil.AtomicWriteFile(dest, bytes.NewReader(b), mode) -} - -// Merge merges the given index file into this index. -// -// This merges by name and version. -// -// If one of the entries in the given index does _not_ already exist, it is added. -// In all other cases, the existing record is preserved. -// -// This can leave the index in an unsorted state -func (i *IndexFile) Merge(f *IndexFile) { - for _, cvs := range f.Entries { - for _, cv := range cvs { - if !i.Has(cv.Name, cv.Version) { - e := i.Entries[cv.Name] - i.Entries[cv.Name] = append(e, cv) - } - } - } -} - -// ChartVersion represents a chart entry in the IndexFile -type ChartVersion struct { - *chart.Metadata - URLs []string `json:"urls"` - Created time.Time `json:"created"` - Removed bool `json:"removed,omitempty"` - Digest string `json:"digest,omitempty"` - - // ChecksumDeprecated is deprecated in Helm 3, and therefore ignored. Helm 3 replaced - // this with Digest. However, with a strict YAML parser enabled, a field must be - // present on the struct for backwards compatibility. - ChecksumDeprecated string `json:"checksum,omitempty"` - - // EngineDeprecated is deprecated in Helm 3, and therefore ignored. However, with a strict - // YAML parser enabled, this field must be present. - EngineDeprecated string `json:"engine,omitempty"` - - // TillerVersionDeprecated is deprecated in Helm 3, and therefore ignored. However, with a strict - // YAML parser enabled, this field must be present. - TillerVersionDeprecated string `json:"tillerVersion,omitempty"` - - // URLDeprecated is deprecated in Helm 3, superseded by URLs. It is ignored. However, - // with a strict YAML parser enabled, this must be present on the struct. - URLDeprecated string `json:"url,omitempty"` -} - -// IndexDirectory reads a (flat) directory and generates an index. -// -// It indexes only charts that have been packaged (*.tgz). -// -// The index returned will be in an unsorted state -func IndexDirectory(dir, baseURL string) (*IndexFile, error) { - archives, err := filepath.Glob(filepath.Join(dir, "*.tgz")) - if err != nil { - return nil, err - } - moreArchives, err := filepath.Glob(filepath.Join(dir, "**/*.tgz")) - if err != nil { - return nil, err - } - archives = append(archives, moreArchives...) - - index := NewIndexFile() - for _, arch := range archives { - fname, err := filepath.Rel(dir, arch) - if err != nil { - return index, err - } - - var parentDir string - parentDir, fname = filepath.Split(fname) - // filepath.Split appends an extra slash to the end of parentDir. We want to strip that out. - parentDir = strings.TrimSuffix(parentDir, string(os.PathSeparator)) - parentURL, err := urlutil.URLJoin(baseURL, parentDir) - if err != nil { - parentURL = path.Join(baseURL, parentDir) - } - - c, err := loader.Load(arch) - if err != nil { - // Assume this is not a chart. - continue - } - hash, err := provenance.DigestFile(arch) - if err != nil { - return index, err - } - if err := index.MustAdd(c.Metadata, fname, parentURL, hash); err != nil { - return index, fmt.Errorf("failed adding to %s to index: %w", fname, err) - } - } - return index, nil -} - -// loadIndex loads an index file and does minimal validity checking. -// -// The source parameter is only used for logging. -// This will fail if API Version is not set (ErrNoAPIVersion) or if the unmarshal fails. -func loadIndex(data []byte, source string) (*IndexFile, error) { - i := &IndexFile{} - - if len(data) == 0 { - return i, ErrEmptyIndexYaml - } - - if err := jsonOrYamlUnmarshal(data, i); err != nil { - return i, err - } - - for name, cvs := range i.Entries { - for idx, v := range slices.Backward(cvs) { - if v == nil { - slog.Warn("skipping loading invalid entry for chart: empty entry", "name", name, "source", source) - cvs = append(cvs[:idx], cvs[idx+1:]...) - continue - } - // When metadata section missing, initialize with no data - if v.Metadata == nil { - v.Metadata = &chart.Metadata{} - } - if v.APIVersion == "" { - v.APIVersion = chart.APIVersionV1 - } - if err := v.Validate(); ignoreSkippableChartValidationError(err) != nil { - slog.Warn("skipping loading invalid entry for chart", "name", name, "version", v.Version, "source", source, "error", err) - cvs = append(cvs[:idx], cvs[idx+1:]...) - } - } - // adjust slice to only contain a set of valid versions - i.Entries[name] = cvs - } - i.SortEntries() - if i.APIVersion == "" { - return i, ErrNoAPIVersion - } - return i, nil -} - -// jsonOrYamlUnmarshal unmarshals the given byte slice containing JSON or YAML -// into the provided interface. -// -// It automatically detects whether the data is in JSON or YAML format by -// checking its validity as JSON. If the data is valid JSON, it will use the -// `encoding/json` package to unmarshal it. Otherwise, it will use the -// `sigs.k8s.io/yaml` package to unmarshal the YAML data. -func jsonOrYamlUnmarshal(b []byte, i any) error { - if json.Valid(b) { - return json.Unmarshal(b, i) - } - return yaml.UnmarshalStrict(b, i) -} - -// ignoreSkippableChartValidationError inspect the given error and returns nil if -// the error isn't important for index loading -// -// In particular, charts may introduce validations that don't impact repository indexes -// And repository indexes may be generated by older/non-compliant software, which doesn't -// conform to all validations. -func ignoreSkippableChartValidationError(err error) error { - verr, ok := err.(chart.ValidationError) - if !ok { - return err - } - - // https://github.com/helm/helm/issues/12748 (JFrog repository strips alias field) - if strings.HasPrefix(verr.Error(), "validation: more than one dependency with name or alias") { - return nil - } - - return err -} +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package repo + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "log/slog" + "os" + "path" + "path/filepath" + "slices" + "sort" + "strings" + "time" + + "github.com/Masterminds/semver/v3" + "sigs.k8s.io/yaml" + + "helm.sh/helm/v4/internal/fileutil" + "helm.sh/helm/v4/internal/urlutil" + chart "helm.sh/helm/v4/pkg/chart/v2" + "helm.sh/helm/v4/pkg/chart/v2/loader" + "helm.sh/helm/v4/pkg/provenance" +) + +// APIVersionV1 is the v1 API version for index and repository files. +const APIVersionV1 = "v1" + +var ( + // ErrNoAPIVersion indicates that an API version was not specified. + ErrNoAPIVersion = errors.New("no API version specified") + // ErrNoChartVersion indicates that a chart with the given version is not found. + ErrNoChartVersion = errors.New("no chart version found") + // ErrNoChartName indicates that a chart with the given name is not found. + ErrNoChartName = errors.New("no chart name found") + // ErrEmptyIndexYaml indicates that the content of index.yaml is empty. + ErrEmptyIndexYaml = errors.New("empty index.yaml file") +) + +// ChartVersions is a list of versioned chart references. +// Implements a sorter on Version. +type ChartVersions []*ChartVersion + +// Len returns the length. +func (c ChartVersions) Len() int { return len(c) } + +// Swap swaps the position of two items in the versions slice. +func (c ChartVersions) Swap(i, j int) { c[i], c[j] = c[j], c[i] } + +// Less returns true if the version of entry a is less than the version of entry b. +func (c ChartVersions) Less(a, b int) bool { + // Failed parse pushes to the back. + i, err := semver.NewVersion(c[a].Version) + if err != nil { + return true + } + j, err := semver.NewVersion(c[b].Version) + if err != nil { + return false + } + return i.LessThan(j) +} + +// IndexFile represents the index file in a chart repository +type IndexFile struct { + // This is used ONLY for validation against chartmuseum's index files and is discarded after validation. + ServerInfo map[string]any `json:"serverInfo,omitempty"` + APIVersion string `json:"apiVersion"` + Generated time.Time `json:"generated"` + Entries map[string]ChartVersions `json:"entries"` + PublicKeys []string `json:"publicKeys,omitempty"` + + // Annotations are additional mappings uninterpreted by Helm. They are made available for + // other applications to add information to the index file. + Annotations map[string]string `json:"annotations,omitempty"` +} + +// NewIndexFile initializes an index. +func NewIndexFile() *IndexFile { + return &IndexFile{ + APIVersion: APIVersionV1, + Generated: time.Now(), + Entries: map[string]ChartVersions{}, + PublicKeys: []string{}, + } +} + +// LoadIndexFile takes a file at the given path and returns an IndexFile object +func LoadIndexFile(path string) (*IndexFile, error) { + i, err := LoadIndexFileForEntries(path, nil) + if err != nil { + var perr *os.PathError + if errors.As(err, &perr) { + return nil, err + } + return nil, fmt.Errorf("error loading %s: %w", path, err) + } + return i, nil +} +// LoadIndexFileForEntries loads an index file but only retains entries +// matching the provided chart names. Unmatched entries are discarded +// immediately after unmarshalling and before validation, reducing heap +// usage when filtering to a small subset of charts in a large repository. +// If names is nil or empty, all entries are loaded (equivalent to LoadIndexFile). +func LoadIndexFileForEntries(path string, names []string) (*IndexFile, error) { + b, err := os.ReadFile(path) + if err != nil { + return nil, err + } + if len(names) == 0 { + i, err := loadIndex(b, path) + if err != nil { + return nil, err + } + return i, nil + } + i := &IndexFile{} + if len(b) == 0 { + return nil, ErrEmptyIndexYaml + } + if err := jsonOrYamlUnmarshal(b, i); err != nil { + return nil, err + } + // Build a new map containing only the requested entries to reduce heap usage. + retained := make(map[string]ChartVersions, len(names)) + for _, n := range names { + if cvs, ok := i.Entries[n]; ok { + retained[n] = cvs + } + } + i.Entries = retained + // Validate and sort only the retained entries. + for name, cvs := range i.Entries { + for idx, v := range slices.Backward(cvs) { + if v == nil { + slog.Warn("skipping loading invalid entry for chart: empty entry", "name", name, "source", path) + cvs = append(cvs[:idx], cvs[idx+1:]...) + continue + } + if v.Metadata == nil { + v.Metadata = &chart.Metadata{} + } + if v.APIVersion == "" { + v.APIVersion = chart.APIVersionV1 + } + if err := v.Validate(); ignoreSkippableChartValidationError(err) != nil { + slog.Warn("skipping loading invalid entry for chart", "name", name, "version", v.Version, "source", path, "error", err) + cvs = append(cvs[:idx], cvs[idx+1:]...) + } + } + i.Entries[name] = cvs + } + i.SortEntries() + if i.APIVersion == "" { + return nil, ErrNoAPIVersion + } + return i, nil +} + +// MustAdd adds a file to the index +// This can leave the index in an unsorted state +func (i IndexFile) MustAdd(md *chart.Metadata, filename, baseURL, digest string) error { + if i.Entries == nil { + return errors.New("entries not initialized") + } + + if md.APIVersion == "" { + md.APIVersion = chart.APIVersionV1 + } + if err := md.Validate(); err != nil { + return fmt.Errorf("validate failed for %s: %w", filename, err) + } + + u := filename + if baseURL != "" { + _, file := filepath.Split(filename) + var err error + u, err = urlutil.URLJoin(baseURL, file) + if err != nil { + u = path.Join(baseURL, file) + } + } + cr := &ChartVersion{ + URLs: []string{u}, + Metadata: md, + Digest: digest, + Created: time.Now(), + } + ee := i.Entries[md.Name] + i.Entries[md.Name] = append(ee, cr) + return nil +} + +// Add adds a file to the index and logs an error. +// +// Deprecated: Use index.MustAdd instead. +func (i IndexFile) Add(md *chart.Metadata, filename, baseURL, digest string) { + if err := i.MustAdd(md, filename, baseURL, digest); err != nil { + slog.Error("skipping loading invalid entry for chart", "name", md.Name, "version", md.Version, "file", filename, "error", err) + } +} + +// Has returns true if the index has an entry for a chart with the given name and exact version. +func (i IndexFile) Has(name, version string) bool { + _, err := i.Get(name, version) + return err == nil +} + +// SortEntries sorts the entries by version in descending order. +// +// In canonical form, the individual version records should be sorted so that +// the most recent release for every version is in the 0th slot in the +// Entries.ChartVersions array. That way, tooling can predict the newest +// version without needing to parse SemVers. +func (i IndexFile) SortEntries() { + for _, versions := range i.Entries { + sort.Sort(sort.Reverse(versions)) + } +} + +// isVersionRange checks if the version string is a range constraint (e.g., "^1", "~1.10") +// rather than an exact version (e.g., "1.10.0"). +func isVersionRange(version string) bool { + if strings.ContainsAny(version, "^~<>=!*") || strings.Contains(version, "||") || strings.Contains(version, " - ") { + return true + } + core := version + if idx := strings.IndexAny(version, "-+"); idx != -1 { + core = version[:idx] + } + return strings.ContainsAny(core, "xX") +} + +// Get returns the ChartVersion for the given name. +// +// If version is empty, this will return the chart with the latest stable version, +// prerelease versions will be skipped. +func (i IndexFile) Get(name, version string) (*ChartVersion, error) { + vs, ok := i.Entries[name] + if !ok { + return nil, ErrNoChartName + } + if len(vs) == 0 { + return nil, ErrNoChartVersion + } + + var constraint *semver.Constraints + if version == "" { + constraint, _ = semver.NewConstraint("*") + } else { + var err error + constraint, err = semver.NewConstraint(version) + if err != nil { + return nil, err + } + } + + // when customer inputs specific version, check whether there's an exact match first + if len(version) != 0 { + for _, ver := range vs { + if version == ver.Version { + return ver, nil + } + } + } + + for _, ver := range vs { + test, err := semver.NewVersion(ver.Version) + if err != nil { + continue + } + + if constraint.Check(test) { + if len(version) != 0 && !isVersionRange(version) { + slog.Warn("unable to find exact version requested; falling back to closest available version", "chart", name, "requested", version, "selected", ver.Version) + } else if len(version) != 0 && isVersionRange(version) { + slog.Debug("selected version matching constraint", "chart", name, "constraint", version, "selected", ver.Version) + } + return ver, nil + } + } + return nil, fmt.Errorf("no chart version found for %s-%s", name, version) +} + +// WriteFile writes an index file to the given destination path. +// +// The mode on the file is set to 'mode'. +func (i IndexFile) WriteFile(dest string, mode os.FileMode) error { + b, err := yaml.Marshal(i) + if err != nil { + return err + } + return fileutil.AtomicWriteFile(dest, bytes.NewReader(b), mode) +} + +// WriteJSONFile writes an index file in JSON format to the given destination +// path. +// +// The mode on the file is set to 'mode'. +func (i IndexFile) WriteJSONFile(dest string, mode os.FileMode) error { + b, err := json.MarshalIndent(i, "", " ") + if err != nil { + return err + } + return fileutil.AtomicWriteFile(dest, bytes.NewReader(b), mode) +} + +// Merge merges the given index file into this index. +// +// This merges by name and version. +// +// If one of the entries in the given index does _not_ already exist, it is added. +// In all other cases, the existing record is preserved. +// +// This can leave the index in an unsorted state +func (i *IndexFile) Merge(f *IndexFile) { + for _, cvs := range f.Entries { + for _, cv := range cvs { + if !i.Has(cv.Name, cv.Version) { + e := i.Entries[cv.Name] + i.Entries[cv.Name] = append(e, cv) + } + } + } +} + +// ChartVersion represents a chart entry in the IndexFile +type ChartVersion struct { + *chart.Metadata + URLs []string `json:"urls"` + Created time.Time `json:"created"` + Removed bool `json:"removed,omitempty"` + Digest string `json:"digest,omitempty"` + + // ChecksumDeprecated is deprecated in Helm 3, and therefore ignored. Helm 3 replaced + // this with Digest. However, with a strict YAML parser enabled, a field must be + // present on the struct for backwards compatibility. + ChecksumDeprecated string `json:"checksum,omitempty"` + + // EngineDeprecated is deprecated in Helm 3, and therefore ignored. However, with a strict + // YAML parser enabled, this field must be present. + EngineDeprecated string `json:"engine,omitempty"` + + // TillerVersionDeprecated is deprecated in Helm 3, and therefore ignored. However, with a strict + // YAML parser enabled, this field must be present. + TillerVersionDeprecated string `json:"tillerVersion,omitempty"` + + // URLDeprecated is deprecated in Helm 3, superseded by URLs. It is ignored. However, + // with a strict YAML parser enabled, this must be present on the struct. + URLDeprecated string `json:"url,omitempty"` +} + +// IndexDirectory reads a (flat) directory and generates an index. +// +// It indexes only charts that have been packaged (*.tgz). +// +// The index returned will be in an unsorted state +func IndexDirectory(dir, baseURL string) (*IndexFile, error) { + archives, err := filepath.Glob(filepath.Join(dir, "*.tgz")) + if err != nil { + return nil, err + } + moreArchives, err := filepath.Glob(filepath.Join(dir, "**/*.tgz")) + if err != nil { + return nil, err + } + archives = append(archives, moreArchives...) + + index := NewIndexFile() + for _, arch := range archives { + fname, err := filepath.Rel(dir, arch) + if err != nil { + return index, err + } + + var parentDir string + parentDir, fname = filepath.Split(fname) + // filepath.Split appends an extra slash to the end of parentDir. We want to strip that out. + parentDir = strings.TrimSuffix(parentDir, string(os.PathSeparator)) + parentURL, err := urlutil.URLJoin(baseURL, parentDir) + if err != nil { + parentURL = path.Join(baseURL, parentDir) + } + + c, err := loader.Load(arch) + if err != nil { + // Assume this is not a chart. + continue + } + hash, err := provenance.DigestFile(arch) + if err != nil { + return index, err + } + if err := index.MustAdd(c.Metadata, fname, parentURL, hash); err != nil { + return index, fmt.Errorf("failed adding to %s to index: %w", fname, err) + } + } + return index, nil +} + +// loadIndex loads an index file and does minimal validity checking. +// +// The source parameter is only used for logging. +// This will fail if API Version is not set (ErrNoAPIVersion) or if the unmarshal fails. +func loadIndex(data []byte, source string) (*IndexFile, error) { + i := &IndexFile{} + + if len(data) == 0 { + return i, ErrEmptyIndexYaml + } + + if err := jsonOrYamlUnmarshal(data, i); err != nil { + return i, err + } + + for name, cvs := range i.Entries { + for idx, v := range slices.Backward(cvs) { + if v == nil { + slog.Warn("skipping loading invalid entry for chart: empty entry", "name", name, "source", source) + cvs = append(cvs[:idx], cvs[idx+1:]...) + continue + } + // When metadata section missing, initialize with no data + if v.Metadata == nil { + v.Metadata = &chart.Metadata{} + } + if v.APIVersion == "" { + v.APIVersion = chart.APIVersionV1 + } + if err := v.Validate(); ignoreSkippableChartValidationError(err) != nil { + slog.Warn("skipping loading invalid entry for chart", "name", name, "version", v.Version, "source", source, "error", err) + cvs = append(cvs[:idx], cvs[idx+1:]...) + } + } + // adjust slice to only contain a set of valid versions + i.Entries[name] = cvs + } + i.SortEntries() + if i.APIVersion == "" { + return nil, ErrNoAPIVersion + } + return i, nil +} + +// jsonOrYamlUnmarshal unmarshals the given byte slice containing JSON or YAML +// into the provided interface. +// +// It automatically detects whether the data is in JSON or YAML format by +// checking its validity as JSON. If the data is valid JSON, it will use the +// `encoding/json` package to unmarshal it. Otherwise, it will use the +// `sigs.k8s.io/yaml` package to unmarshal the YAML data. +func jsonOrYamlUnmarshal(b []byte, i any) error { + if json.Valid(b) { + return json.Unmarshal(b, i) + } + return yaml.UnmarshalStrict(b, i) +} + +// ignoreSkippableChartValidationError inspect the given error and returns nil if +// the error isn't important for index loading +// +// In particular, charts may introduce validations that don't impact repository indexes +// And repository indexes may be generated by older/non-compliant software, which doesn't +// conform to all validations. +func ignoreSkippableChartValidationError(err error) error { + verr, ok := err.(chart.ValidationError) + if !ok { + return err + } + + // https://github.com/helm/helm/issues/12748 (JFrog repository strips alias field) + if strings.HasPrefix(verr.Error(), "validation: more than one dependency with name or alias") { + return nil + } + + return err +} diff --git a/pkg/repo/v1/index_test.go b/pkg/repo/v1/index_test.go index 126be125f..543d9b78a 100644 --- a/pkg/repo/v1/index_test.go +++ b/pkg/repo/v1/index_test.go @@ -757,3 +757,62 @@ func TestIsVersionRange(t *testing.T) { }) } } + +func TestLoadIndexFileForEntries(t *testing.T) { + // nil names loads all entries + t.Run("nil names loads all", func(t *testing.T) { + i, err := LoadIndexFileForEntries("testdata/local-index.yaml", nil) + if err != nil { + t.Fatal(err) + } + if len(i.Entries) == 0 { + t.Fatal("expected entries, got none") + } + }) + + t.Run("empty names loads all", func(t *testing.T) { + i, err := LoadIndexFileForEntries("testdata/local-index.yaml", []string{}) + if err != nil { + t.Fatal(err) + } + if len(i.Entries) == 0 { + t.Fatal("expected entries, got none") + } + }) + + // filter to a specific chart + t.Run("filter to one entry", func(t *testing.T) { + i, err := LoadIndexFileForEntries("testdata/local-index.yaml", []string{"alpine"}) + if err != nil { + t.Fatal(err) + } + if len(i.Entries) != 1 { + t.Fatalf("expected 1 entry, got %d", len(i.Entries)) + } + if _, ok := i.Entries["alpine"]; !ok { + t.Fatal("expected 'alpine' entry") + } + }) + + // no matches returns empty entries + t.Run("no matches returns empty", func(t *testing.T) { + i, err := LoadIndexFileForEntries("testdata/local-index.yaml", []string{"nonexistent-chart"}) + if err != nil { + t.Fatal(err) + } + if len(i.Entries) != 0 { + t.Fatalf("expected 0 entries, got %d", len(i.Entries)) + } + }) + + // partial match retains only matched entries + t.Run("partial match", func(t *testing.T) { + i, err := LoadIndexFileForEntries("testdata/local-index.yaml", []string{"nginx", "nonexistent-chart"}) + if err != nil { + t.Fatal(err) + } + if len(i.Entries) != 1 { + t.Fatalf("expected 1 entry, got %d", len(i.Entries)) + } + }) +}