From 00308ca35faa1cf59a451edd29783c95d30d9284 Mon Sep 17 00:00:00 2001 From: Varun Chawla Date: Mon, 16 Feb 2026 16:32:19 -0800 Subject: [PATCH] feat: add OCI registry support for helm search repo --versions Implements chart version discovery for OCI registries via the /v2/{name}/tags/list endpoint defined in the OCI Distribution Spec. When an oci:// reference is passed to `helm search repo`, the command now lists available chart versions by querying the registry's tag list and fetching chart metadata from each tag's config layer. This avoids downloading the full chart tarball - only the lightweight config layer (which contains Chart.yaml metadata) is fetched. Usage: helm search repo oci://ghcr.io/org/charts/mychart --versions helm search repo oci://ghcr.io/org/charts/mychart --version '^1.0.0' helm search repo oci://ghcr.io/org/charts/mychart --devel Fixes #11000 Signed-off-by: Varun Chawla --- pkg/cmd/search_repo.go | 122 ++++++++++++++++++++++++- pkg/registry/search.go | 174 ++++++++++++++++++++++++++++++++++++ pkg/registry/search_test.go | 67 ++++++++++++++ 3 files changed, 362 insertions(+), 1 deletion(-) create mode 100644 pkg/registry/search.go create mode 100644 pkg/registry/search_test.go diff --git a/pkg/cmd/search_repo.go b/pkg/cmd/search_repo.go index febb138e2..27af5f695 100644 --- a/pkg/cmd/search_repo.go +++ b/pkg/cmd/search_repo.go @@ -34,6 +34,7 @@ import ( "helm.sh/helm/v4/pkg/cli/output" "helm.sh/helm/v4/pkg/cmd/search" "helm.sh/helm/v4/pkg/helmpath" + "helm.sh/helm/v4/pkg/registry" "helm.sh/helm/v4/pkg/repo/v1" ) @@ -42,10 +43,17 @@ Search reads through all of the repositories configured on the system, and looks for matches. Search of these repositories uses the metadata stored on the system. +Repositories are managed with 'helm repo' commands. + It will display the latest stable versions of the charts found. If you specify the --devel flag, the output will include pre-release versions. If you want to search using a version constraint, use --version. +OCI registries are also supported. You can search for chart versions in an +OCI registry by providing an oci:// reference: + + $ helm search repo oci://registry/repo/chart --versions + Examples: # Search for stable release versions matching the keyword "nginx" @@ -57,7 +65,11 @@ Examples: # Search for the latest stable release for nginx-ingress with a major version of 1 $ helm search repo nginx-ingress --version ^1.0.0 -Repositories are managed with 'helm repo' commands. + # List all available versions of a chart in an OCI registry + $ helm search repo oci://ghcr.io/org/charts/mychart --versions + + # Search for the latest stable release of an OCI chart + $ helm search repo oci://ghcr.io/org/charts/mychart ` // searchMaxScore suggests that any score higher than this is not considered a match. @@ -73,6 +85,15 @@ type searchRepoOptions struct { repoCacheDir string outputFormat output.Format failOnNoResult bool + + // OCI-related options + certFile string + keyFile string + caFile string + insecureSkipTLSverify bool + plainHTTP bool + username string + password string } func newSearchRepoCmd(out io.Writer) *cobra.Command { @@ -97,6 +118,15 @@ func newSearchRepoCmd(out io.Writer) *cobra.Command { f.UintVar(&o.maxColWidth, "max-col-width", 50, "maximum column width for output table") f.BoolVar(&o.failOnNoResult, "fail-on-no-result", false, "search fails if no results are found") + // OCI-related flags + f.StringVar(&o.certFile, "cert-file", "", "identify registry client using this SSL certificate file") + f.StringVar(&o.keyFile, "key-file", "", "identify registry client using this SSL key file") + f.StringVar(&o.caFile, "ca-file", "", "verify certificates of HTTPS-enabled servers using this CA bundle") + f.BoolVar(&o.insecureSkipTLSverify, "insecure-skip-tls-verify", false, "skip tls certificate checks for the chart download") + f.BoolVar(&o.plainHTTP, "plain-http", false, "use insecure HTTP connections for the chart download") + f.StringVar(&o.username, "username", "", "chart repository username") + f.StringVar(&o.password, "password", "", "chart repository password") + bindOutputFlag(cmd, &o.outputFormat) return cmd @@ -105,6 +135,11 @@ func newSearchRepoCmd(out io.Writer) *cobra.Command { func (o *searchRepoOptions) run(out io.Writer, args []string) error { o.setupSearchedVersion() + // Check if any argument is an OCI reference + if len(args) > 0 && registry.IsOCI(args[0]) { + return o.runOCI(out, args[0]) + } + index, err := o.buildIndex() if err != nil { return err @@ -130,6 +165,34 @@ func (o *searchRepoOptions) run(out io.Writer, args []string) error { return o.outputFormat.Write(out, &repoSearchWriter{data, o.maxColWidth, o.failOnNoResult}) } +// runOCI handles searching for chart versions in an OCI registry. +func (o *searchRepoOptions) runOCI(out io.Writer, ociRef string) error { + registryClient, err := newRegistryClient( + o.certFile, o.keyFile, o.caFile, + o.insecureSkipTLSverify, o.plainHTTP, + o.username, o.password, + ) + if err != nil { + return fmt.Errorf("missing registry client: %w", err) + } + + // Strip the oci:// prefix for the registry client + ref := strings.TrimPrefix(ociRef, fmt.Sprintf("%s://", registry.OCIScheme)) + + var searchOpts []registry.SearchOption + searchOpts = append(searchOpts, + registry.SearchOptVersion(o.version), + registry.SearchOptVersions(o.versions), + ) + + searchResult, err := registryClient.Search(ref, searchOpts...) + if err != nil { + return err + } + + return o.outputFormat.Write(out, &ociSearchWriter{searchResult.Charts, o.maxColWidth, o.failOnNoResult}) +} + func (o *searchRepoOptions) setupSearchedVersion() { slog.Debug("original chart version", "version", o.version) @@ -267,6 +330,63 @@ func (r *repoSearchWriter) encodeByFormat(out io.Writer, format output.Format) e } } +// ociSearchWriter handles output for OCI registry search results +type ociSearchWriter struct { + results []*registry.SearchResultChart + columnWidth uint + failOnNoResult bool +} + +func (r *ociSearchWriter) WriteTable(out io.Writer) error { + if len(r.results) == 0 { + if r.failOnNoResult { + return fmt.Errorf("no results found") + } + + _, err := out.Write([]byte("No results found\n")) + if err != nil { + return fmt.Errorf("unable to write results: %s", err) + } + return nil + } + table := uitable.New() + table.MaxColWidth = r.columnWidth + table.AddRow("NAME", "CHART VERSION", "APP VERSION", "DESCRIPTION") + for _, r := range r.results { + table.AddRow(r.Reference, r.Chart.Version, r.Chart.AppVersion, r.Chart.Description) + } + return output.EncodeTable(out, table) +} + +func (r *ociSearchWriter) WriteJSON(out io.Writer) error { + return r.encodeByFormat(out, output.JSON) +} + +func (r *ociSearchWriter) WriteYAML(out io.Writer) error { + return r.encodeByFormat(out, output.YAML) +} + +func (r *ociSearchWriter) encodeByFormat(out io.Writer, format output.Format) error { + if len(r.results) == 0 && r.failOnNoResult { + return fmt.Errorf("no results found") + } + + chartList := make([]repoChartElement, 0, len(r.results)) + + for _, r := range r.results { + chartList = append(chartList, repoChartElement{r.Reference, r.Chart.Version, r.Chart.AppVersion, r.Chart.Description}) + } + + switch format { + case output.JSON: + return output.EncodeJSON(out, chartList) + case output.YAML: + return output.EncodeYAML(out, chartList) + default: + return nil + } +} + // Provides the list of charts that are part of the specified repo, and that starts with 'prefix'. func compListChartsOfRepo(repoName string, prefix string) []string { var charts []string diff --git a/pkg/registry/search.go b/pkg/registry/search.go new file mode 100644 index 000000000..79d8bf29b --- /dev/null +++ b/pkg/registry/search.go @@ -0,0 +1,174 @@ +/* +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 registry // import "helm.sh/helm/v4/pkg/registry" + +import ( + "encoding/json" + "fmt" + "log/slog" + "strings" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + + chart "helm.sh/helm/v4/pkg/chart/v2" +) + +type ( + // SearchOption allows specifying various settings on search + SearchOption func(*searchOperation) + + searchOperation struct { + version string + versions bool + } + + // SearchResult is the result returned upon successful search. + SearchResult struct { + Charts []*SearchResultChart `json:"charts"` + } + + // SearchResultChart represents a single chart version found in a registry. + SearchResultChart struct { + // Reference is the full OCI reference (e.g., oci://registry/repo/chart) + Reference string `json:"reference"` + // Chart contains the chart metadata extracted from the OCI config layer + Chart *chart.Metadata `json:"chart"` + } +) + +// SearchOptVersion sets the version constraint for search +func SearchOptVersion(version string) SearchOption { + return func(operation *searchOperation) { + operation.version = version + } +} + +// SearchOptVersions sets whether to return all matching versions +func SearchOptVersions(versions bool) SearchOption { + return func(operation *searchOperation) { + operation.versions = versions + } +} + +// Search queries an OCI registry for chart versions matching the given reference. +// It lists all tags for the repository, filters by semver constraint, and fetches +// chart metadata from each matching tag's config layer. +func (c *Client) Search(ref string, options ...SearchOption) (*SearchResult, error) { + searchResult := &SearchResult{ + Charts: []*SearchResultChart{}, + } + + operation := &searchOperation{} + for _, option := range options { + option(operation) + } + + // List all tags for the repository + tags, err := c.Tags(ref) + if err != nil { + // If the registry doesn't support tag listing, return empty results + if strings.Contains(err.Error(), "unexpected status code") { + slog.Debug("registry does not support tag listing", slog.String("ref", ref), slog.Any("error", err)) + return searchResult, nil + } + return searchResult, err + } + + // Filter tags by version constraint + var matchingTags []string + for _, tag := range tags { + match, err := GetTagMatchingVersionOrConstraint([]string{tag}, operation.version) + if err == nil { + matchingTags = append(matchingTags, match) + } + } + + parsedRef, err := newReference(ref) + if err != nil { + return searchResult, err + } + + ociRef := fmt.Sprintf("%s://%s/%s", OCIScheme, parsedRef.Registry, parsedRef.Repository) + + // Fetch chart metadata for each matching tag + for _, tag := range matchingTags { + tagRef := fmt.Sprintf("%s/%s:%s", parsedRef.Registry, parsedRef.Repository, strings.ReplaceAll(tag, "+", "_")) + + meta, err := c.fetchChartMetadata(tagRef) + if err != nil { + slog.Debug("failed to fetch chart metadata", slog.String("ref", tagRef), slog.Any("error", err)) + continue + } + + searchResult.Charts = append(searchResult.Charts, &SearchResultChart{ + Reference: ociRef, + Chart: meta, + }) + + // If not listing all versions, return only the latest (first match, since tags are sorted descending) + if !operation.versions { + break + } + } + + return searchResult, nil +} + +// fetchChartMetadata pulls only the config layer from an OCI manifest to extract chart metadata. +// This avoids downloading the full chart tarball. +func (c *Client) fetchChartMetadata(ref string) (*chart.Metadata, error) { + genericClient := c.Generic() + + // Only fetch the manifest and config layer, skip the chart tarball + genericResult, err := genericClient.PullGeneric(ref, GenericPullOptions{ + AllowedMediaTypes: []string{ + ocispec.MediaTypeImageManifest, + ocispec.MediaTypeImageIndex, + ConfigMediaType, + }, + }) + if err != nil { + return nil, err + } + + // Find the config descriptor + var configDescriptor *ocispec.Descriptor + for _, desc := range genericResult.Descriptors { + d := desc + if d.MediaType == ConfigMediaType { + configDescriptor = &d + break + } + } + + if configDescriptor == nil { + return nil, fmt.Errorf("could not find config layer with mediatype %s", ConfigMediaType) + } + + // Fetch and parse the config data + configData, err := genericClient.GetDescriptorData(genericResult.MemoryStore, *configDescriptor) + if err != nil { + return nil, fmt.Errorf("unable to retrieve config blob: %w", err) + } + + var meta chart.Metadata + if err := json.Unmarshal(configData, &meta); err != nil { + return nil, fmt.Errorf("unable to parse chart metadata: %w", err) + } + + return &meta, nil +} diff --git a/pkg/registry/search_test.go b/pkg/registry/search_test.go new file mode 100644 index 000000000..f862e046c --- /dev/null +++ b/pkg/registry/search_test.go @@ -0,0 +1,67 @@ +/* +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 registry + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + chart "helm.sh/helm/v4/pkg/chart/v2" +) + +func TestSearchOptVersion(t *testing.T) { + op := &searchOperation{} + SearchOptVersion(">=1.0.0")(op) + assert.Equal(t, ">=1.0.0", op.version) +} + +func TestSearchOptVersions(t *testing.T) { + op := &searchOperation{} + SearchOptVersions(true)(op) + assert.True(t, op.versions) +} + +func TestSearchResult(t *testing.T) { + result := &SearchResult{ + Charts: []*SearchResultChart{ + { + Reference: "oci://ghcr.io/org/charts/mychart", + Chart: &chart.Metadata{ + Name: "mychart", + Version: "1.2.0", + AppVersion: "2.0.0", + Description: "A test chart", + }, + }, + { + Reference: "oci://ghcr.io/org/charts/mychart", + Chart: &chart.Metadata{ + Name: "mychart", + Version: "1.1.0", + AppVersion: "1.9.0", + Description: "A test chart", + }, + }, + }, + } + + assert.Equal(t, 2, len(result.Charts)) + assert.Equal(t, "1.2.0", result.Charts[0].Chart.Version) + assert.Equal(t, "1.1.0", result.Charts[1].Chart.Version) + assert.Equal(t, "oci://ghcr.io/org/charts/mychart", result.Charts[0].Reference) +}