From 60172f8445e797ba528a1c3e1332ffb23e65861c Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Thu, 27 Nov 2025 00:41:32 +0300 Subject: [PATCH] feat(registry): support selecting artifacts from OCI Image Index When helm pull encounters an OCI Image Index containing multiple manifests, it now selects the appropriate Helm chart manifest by checking the artifactType field (OCI 1.1) or falling back to config.mediaType inspection. This enables publishing both container images and Helm charts under the same OCI tag, similar to how multi-arch images work. Selection logic: 1. First, check for explicit artifactType match in Index descriptors 2. If no match, fetch manifests without platform and check config.mediaType 3. Skip manifests with platform selector (likely container images) Closes: https://github.com/helm/helm/issues/31582 Co-Authored-By: Claude Signed-off-by: Aleksei Sviridkin --- pkg/registry/client.go | 2 + pkg/registry/constants.go | 4 + pkg/registry/generic.go | 89 ++++++++++- pkg/registry/index_test.go | 295 +++++++++++++++++++++++++++++++++++++ pkg/registry/plugin.go | 2 + 5 files changed, 391 insertions(+), 1 deletion(-) create mode 100644 pkg/registry/index_test.go diff --git a/pkg/registry/client.go b/pkg/registry/client.go index 750bb9715..f706c5fc7 100644 --- a/pkg/registry/client.go +++ b/pkg/registry/client.go @@ -577,9 +577,11 @@ func (c *Client) Pull(ref string, options ...PullOption) (*PullResult, error) { } // Use generic client for the pull operation + // Pass ChartArtifactType to enable selection from OCI Image Index genericClient := c.Generic() genericResult, err := genericClient.PullGeneric(ref, GenericPullOptions{ AllowedMediaTypes: allowedMediaTypes, + ArtifactType: ChartArtifactType, }) if err != nil { return nil, err diff --git a/pkg/registry/constants.go b/pkg/registry/constants.go index c455cf314..a79c65c0f 100644 --- a/pkg/registry/constants.go +++ b/pkg/registry/constants.go @@ -34,4 +34,8 @@ const ( // LegacyChartLayerMediaType is the legacy reserved media type for Helm chart package content. LegacyChartLayerMediaType = "application/tar+gzip" + + // ChartArtifactType is the artifact type for Helm charts in OCI v1.1+ Image Index. + // This is used to identify Helm chart manifests within a multi-artifact index. + ChartArtifactType = "application/vnd.cncf.helm.config.v1+json" ) diff --git a/pkg/registry/generic.go b/pkg/registry/generic.go index b46133d91..e37a6db52 100644 --- a/pkg/registry/generic.go +++ b/pkg/registry/generic.go @@ -18,6 +18,8 @@ package registry import ( "context" + "encoding/json" + "fmt" "io" "net/http" "slices" @@ -56,6 +58,10 @@ type GenericPullOptions struct { SkipMediaTypes []string // Custom PreCopy function for filtering PreCopy func(context.Context, ocispec.Descriptor) error + // ArtifactType to select from OCI Image Index (empty means no filtering). + // When pulling from an Image Index containing multiple manifests, + // this field is used to select the manifest with matching artifactType. + ArtifactType string } // GenericPullResult contains the result of a generic pull operation @@ -83,6 +89,67 @@ func NewGenericClient(client *Client) *GenericClient { } } +// resolveFromIndex selects a manifest from an OCI Image Index by artifactType. +// It returns the descriptor of the matching manifest, or an error if no match is found. +// If no manifests have artifactType set, it falls back to checking config.mediaType +// of each manifest to find one that matches the expected artifact type. +func (c *GenericClient) resolveFromIndex(ctx context.Context, repo *remote.Repository, indexDesc ocispec.Descriptor, artifactType string) (ocispec.Descriptor, error) { + // Fetch the index manifest + indexData, err := content.FetchAll(ctx, repo, indexDesc) + if err != nil { + return ocispec.Descriptor{}, fmt.Errorf("unable to fetch image index: %w", err) + } + + var index ocispec.Index + if err := json.Unmarshal(indexData, &index); err != nil { + return ocispec.Descriptor{}, fmt.Errorf("unable to parse image index: %w", err) + } + + // First pass: look for explicit artifactType match + var availableTypes []string + var candidatesWithoutArtifactType []ocispec.Descriptor + for _, manifest := range index.Manifests { + if manifest.ArtifactType == artifactType { + return manifest, nil + } + if manifest.ArtifactType != "" { + availableTypes = append(availableTypes, manifest.ArtifactType) + } else { + // Collect manifests without artifactType for fallback check + candidatesWithoutArtifactType = append(candidatesWithoutArtifactType, manifest) + } + } + + // Second pass: if no artifactType matches found, check config.mediaType of each manifest + // This handles the case where artifacts are published without explicit artifactType + for _, candidate := range candidatesWithoutArtifactType { + // Skip manifests with platform (likely container images) + if candidate.Platform != nil { + continue + } + + // Fetch the manifest to check its config.mediaType + manifestData, err := content.FetchAll(ctx, repo, candidate) + if err != nil { + continue // Skip manifests we can't fetch + } + + var manifest ocispec.Manifest + if err := json.Unmarshal(manifestData, &manifest); err != nil { + continue // Skip malformed manifests + } + + // Check if config.mediaType matches our expected artifact type + if manifest.Config.MediaType == artifactType { + return candidate, nil + } + } + + return ocispec.Descriptor{}, fmt.Errorf( + "no manifest with artifactType %q found in image index; available types: %v", + artifactType, availableTypes) +} + // PullGeneric performs a generic OCI pull without artifact-specific assumptions func (c *GenericClient) PullGeneric(ref string, options GenericPullOptions) (*GenericPullResult, error) { parsedRef, err := newReference(ref) @@ -103,6 +170,26 @@ func (c *GenericClient) PullGeneric(ref string, options GenericPullOptions) (*Ge ctx := context.Background() + // Resolve the reference to get the manifest descriptor + // This allows us to detect Image Index and select the appropriate manifest + pullRef := parsedRef.String() + if options.ArtifactType != "" { + // Try to resolve the reference to check if it's an Image Index. + // If resolution fails, continue with normal pull - the error will + // manifest during oras.Copy() if there's a real problem. + resolvedDesc, err := repository.Resolve(ctx, pullRef) + if err == nil && resolvedDesc.MediaType == ocispec.MediaTypeImageIndex { + // Select the manifest with matching artifactType from the index + selectedManifest, err := c.resolveFromIndex(ctx, repository, resolvedDesc, options.ArtifactType) + if err != nil { + return nil, err + } + // Use the selected manifest's digest for pulling + pullRef = selectedManifest.Digest.String() + } + // If Resolve() failed or it's not an Image Index, continue with original pullRef + } + // Prepare allowed media types for filtering var allowedMediaTypes []string if len(options.AllowedMediaTypes) > 0 { @@ -112,7 +199,7 @@ func (c *GenericClient) PullGeneric(ref string, options GenericPullOptions) (*Ge } var mu sync.Mutex - manifest, err := oras.Copy(ctx, repository, parsedRef.String(), memoryStore, "", oras.CopyOptions{ + manifest, err := oras.Copy(ctx, repository, pullRef, memoryStore, "", oras.CopyOptions{ CopyGraphOptions: oras.CopyGraphOptions{ PreCopy: func(ctx context.Context, desc ocispec.Descriptor) error { // Apply a custom PreCopy function if provided diff --git a/pkg/registry/index_test.go b/pkg/registry/index_test.go new file mode 100644 index 000000000..d25b04328 --- /dev/null +++ b/pkg/registry/index_test.go @@ -0,0 +1,295 @@ +/* +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 ( + "crypto/sha256" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/opencontainers/go-digest" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// computeDigest calculates the SHA256 digest of data. +func computeDigest(data []byte) digest.Digest { + h := sha256.Sum256(data) + return digest.NewDigestFromBytes(digest.SHA256, h[:]) +} + +func TestPullFromImageIndex(t *testing.T) { + // Build chart config and layer with real digests + chartConfigData := []byte(`{"name":"testchart","version":"1.0.0","apiVersion":"v2"}`) + chartConfigDigest := computeDigest(chartConfigData) + + // Minimal valid gzipped content + chartLayerData := []byte{0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00} + chartLayerDigest := computeDigest(chartLayerData) + + // Create chart manifest with real digests + chartManifest := ocispec.Manifest{ + MediaType: ocispec.MediaTypeImageManifest, + Config: ocispec.Descriptor{ + MediaType: ConfigMediaType, + Digest: chartConfigDigest, + Size: int64(len(chartConfigData)), + }, + Layers: []ocispec.Descriptor{ + { + MediaType: ChartLayerMediaType, + Digest: chartLayerDigest, + Size: int64(len(chartLayerData)), + }, + }, + } + chartManifestBytes, _ := json.Marshal(chartManifest) + chartManifestDigest := computeDigest(chartManifestBytes) + + // Container manifest (we won't actually serve the blobs, just need valid structure) + containerManifestDigest := digest.Digest("sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff") + + // Image Index containing both chart and container manifests + imageIndex := ocispec.Index{ + MediaType: ocispec.MediaTypeImageIndex, + Manifests: []ocispec.Descriptor{ + { + MediaType: ocispec.MediaTypeImageManifest, + Digest: containerManifestDigest, + Size: 500, + ArtifactType: "application/vnd.oci.image.config.v1+json", + Platform: &ocispec.Platform{ + Architecture: "amd64", + OS: "linux", + }, + }, + { + MediaType: ocispec.MediaTypeImageManifest, + Digest: chartManifestDigest, + Size: int64(len(chartManifestBytes)), + ArtifactType: ChartArtifactType, + }, + }, + } + imageIndexBytes, _ := json.Marshal(imageIndex) + imageIndexDigest := computeDigest(imageIndexBytes) + + // Create test server that serves the Image Index + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + switch { + case path == "/v2/": + w.WriteHeader(http.StatusOK) + + case path == "/v2/testrepo/multichart/manifests/1.0.0": + w.Header().Set("Content-Type", ocispec.MediaTypeImageIndex) + w.Header().Set("Docker-Content-Digest", imageIndexDigest.String()) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(imageIndexBytes) + + // Serve Image Index by digest (for resolveFromIndex FetchAll) + case path == fmt.Sprintf("/v2/testrepo/multichart/blobs/%s", imageIndexDigest.String()), + path == fmt.Sprintf("/v2/testrepo/multichart/manifests/%s", imageIndexDigest.String()): + w.Header().Set("Content-Type", ocispec.MediaTypeImageIndex) + w.Header().Set("Docker-Content-Digest", imageIndexDigest.String()) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(imageIndexBytes) + + // Serve chart manifest by digest + case path == fmt.Sprintf("/v2/testrepo/multichart/manifests/%s", chartManifestDigest.String()): + w.Header().Set("Content-Type", ocispec.MediaTypeImageManifest) + w.Header().Set("Docker-Content-Digest", chartManifestDigest.String()) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(chartManifestBytes) + + // Serve chart config blob + case strings.Contains(path, chartConfigDigest.Encoded()): + w.Header().Set("Content-Type", ConfigMediaType) + w.Header().Set("Docker-Content-Digest", chartConfigDigest.String()) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(chartConfigData) + + // Serve chart layer blob + case strings.Contains(path, chartLayerDigest.Encoded()): + w.Header().Set("Content-Type", ChartLayerMediaType) + w.Header().Set("Docker-Content-Digest", chartLayerDigest.String()) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(chartLayerData) + + default: + t.Logf("404 for path: %s", path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer s.Close() + + u, _ := url.Parse(s.URL) + host := fmt.Sprintf("localhost:%s", u.Port()) + ref := fmt.Sprintf("%s/testrepo/multichart:1.0.0", host) + + client, err := NewClient(ClientOptPlainHTTP()) + require.NoError(t, err) + + // Pull should automatically select the chart manifest from the index + result, err := client.Pull(ref) + require.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, "testchart", result.Chart.Meta.Name) + assert.Equal(t, "1.0.0", result.Chart.Meta.Version) +} + +func TestPullFromImageIndexNoMatchingArtifactType(t *testing.T) { + // Image Index with only container images, no Helm chart + imageIndex := ocispec.Index{ + MediaType: ocispec.MediaTypeImageIndex, + Manifests: []ocispec.Descriptor{ + { + MediaType: ocispec.MediaTypeImageManifest, + Digest: "sha256:2222222222222222222222222222222222222222222222222222222222222222", + Size: 500, + ArtifactType: "application/vnd.oci.image.config.v1+json", + Platform: &ocispec.Platform{ + Architecture: "amd64", + OS: "linux", + }, + }, + { + MediaType: ocispec.MediaTypeImageManifest, + Digest: "sha256:3333333333333333333333333333333333333333333333333333333333333333", + Size: 500, + ArtifactType: "application/vnd.oci.image.config.v1+json", + Platform: &ocispec.Platform{ + Architecture: "arm64", + OS: "linux", + }, + }, + }, + } + imageIndexBytes, _ := json.Marshal(imageIndex) + imageIndexDigest := computeDigest(imageIndexBytes) + + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + switch { + case path == "/v2/": + w.WriteHeader(http.StatusOK) + case path == "/v2/testrepo/nohelm/manifests/1.0.0": + w.Header().Set("Content-Type", ocispec.MediaTypeImageIndex) + w.Header().Set("Docker-Content-Digest", imageIndexDigest.String()) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(imageIndexBytes) + // Serve Image Index by digest + case path == fmt.Sprintf("/v2/testrepo/nohelm/blobs/%s", imageIndexDigest.String()), + path == fmt.Sprintf("/v2/testrepo/nohelm/manifests/%s", imageIndexDigest.String()): + w.Header().Set("Content-Type", ocispec.MediaTypeImageIndex) + w.Header().Set("Docker-Content-Digest", imageIndexDigest.String()) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(imageIndexBytes) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer s.Close() + + u, _ := url.Parse(s.URL) + host := fmt.Sprintf("localhost:%s", u.Port()) + ref := fmt.Sprintf("%s/testrepo/nohelm:1.0.0", host) + + client, err := NewClient(ClientOptPlainHTTP()) + require.NoError(t, err) + + _, err = client.Pull(ref) + require.Error(t, err) + assert.Contains(t, err.Error(), "no manifest with artifactType") + assert.Contains(t, err.Error(), ChartArtifactType) +} + +func TestPullSingleManifestNotIndex(t *testing.T) { + // Regular manifest (not an Index) should work as before + // Build config and layer with real digests + configData := []byte(`{"name":"singlechart","version":"1.0.0","apiVersion":"v2"}`) + configDigest := computeDigest(configData) + + layerData := []byte{0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00} + layerDigest := computeDigest(layerData) + + manifest := ocispec.Manifest{ + MediaType: ocispec.MediaTypeImageManifest, + Config: ocispec.Descriptor{ + MediaType: ConfigMediaType, + Digest: configDigest, + Size: int64(len(configData)), + }, + Layers: []ocispec.Descriptor{ + { + MediaType: ChartLayerMediaType, + Digest: layerDigest, + Size: int64(len(layerData)), + }, + }, + } + manifestBytes, _ := json.Marshal(manifest) + manifestDigest := computeDigest(manifestBytes) + + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + switch { + case path == "/v2/": + w.WriteHeader(http.StatusOK) + + case path == "/v2/testrepo/singlechart/manifests/1.0.0": + w.Header().Set("Content-Type", ocispec.MediaTypeImageManifest) + w.Header().Set("Docker-Content-Digest", manifestDigest.String()) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(manifestBytes) + + case strings.Contains(path, configDigest.Encoded()): + w.Header().Set("Content-Type", ConfigMediaType) + w.Header().Set("Docker-Content-Digest", configDigest.String()) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(configData) + + case strings.Contains(path, layerDigest.Encoded()): + w.Header().Set("Content-Type", ChartLayerMediaType) + w.Header().Set("Docker-Content-Digest", layerDigest.String()) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(layerData) + + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer s.Close() + + u, _ := url.Parse(s.URL) + host := fmt.Sprintf("localhost:%s", u.Port()) + ref := fmt.Sprintf("%s/testrepo/singlechart:1.0.0", host) + + client, err := NewClient(ClientOptPlainHTTP()) + require.NoError(t, err) + + result, err := client.Pull(ref) + require.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, "singlechart", result.Chart.Meta.Name) +} diff --git a/pkg/registry/plugin.go b/pkg/registry/plugin.go index e4b4afa24..083819335 100644 --- a/pkg/registry/plugin.go +++ b/pkg/registry/plugin.go @@ -57,6 +57,7 @@ func (c *Client) PullPlugin(ref string, pluginName string, options ...PluginPull } // Use generic client for the pull operation with artifact type filtering + // Pass PluginArtifactType to enable selection from OCI Image Index genericClient := c.Generic() genericResult, err := genericClient.PullGeneric(ref, GenericPullOptions{ // Allow manifests and all layer types - we'll validate artifact type after download @@ -65,6 +66,7 @@ func (c *Client) PullPlugin(ref string, pluginName string, options ...PluginPull "application/vnd.oci.image.layer.v1.tar", "application/vnd.oci.image.layer.v1.tar+gzip", }, + ArtifactType: PluginArtifactType, }) if err != nil { return nil, err