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 <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
pull/31583/head
Aleksei Sviridkin 1 month ago
parent ff35414bed
commit 60172f8445
No known key found for this signature in database
GPG Key ID: 7988329FDF395282

@ -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

@ -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"
)

@ -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

@ -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)
}

@ -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

Loading…
Cancel
Save