feat(registry): OCI 1.1 artifact support for Helm charts

Add OCI 1.1 artifact support for charts (HIP: helm/community#424):

- Select a chart from an OCI Image Index by descriptor artifactType,
  disambiguating multiple charts by the requested name then version, and
  falling back to the chart config for legacy indexes without
  annotations. An unresolvable choice errors with the candidate list
  instead of returning a position-dependent, mislabeled match.
- Set artifactType (and chart name/version annotations) on helm push so
  charts are identifiable at the Index level without fetching each
  manifest.
- Add a --subject flag to helm push that associates the chart with an
  existing artifact in the same repository via the OCI Referrers API;
  the digest is validated and resolved against the target repository to
  a full descriptor.

Closes #31582

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
pull/31583/head
Aleksei Sviridkin 3 months ago committed by Aleksei Sviridkin
parent f3d68cdbea
commit 24ab25c82d
No known key found for this signature in database
GPG Key ID: 7988329FDF395282

@ -38,6 +38,7 @@ type Push struct {
insecureSkipTLSVerify bool
plainHTTP bool
out io.Writer
subject string
}
// PushOpt is a type of function that sets options for a push action.
@ -80,6 +81,14 @@ func WithPushOptWriter(out io.Writer) PushOpt {
}
}
// WithSubject sets the subject digest for OCI Referrers API.
// When set, the pushed chart will be associated with the specified image digest.
func WithSubject(subject string) PushOpt {
return func(p *Push) {
p.subject = subject
}
}
// NewPushWithOpts creates a new push, with configuration options.
func NewPushWithOpts(opts ...PushOpt) *Push {
p := &Push{}
@ -100,6 +109,7 @@ func (p *Push) Run(chartRef string, remote string) (string, error) {
pusher.WithTLSClientConfig(p.certFile, p.keyFile, p.caFile),
pusher.WithInsecureSkipTLSVerify(p.insecureSkipTLSVerify),
pusher.WithPlainHTTP(p.plainHTTP),
pusher.WithSubject(p.subject),
},
}

@ -42,6 +42,7 @@ type registryPushOptions struct {
plainHTTP bool
password string
username string
subject string
}
func newPushCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
@ -83,7 +84,8 @@ func newPushCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
action.WithTLSClientConfig(o.certFile, o.keyFile, o.caFile),
action.WithInsecureSkipTLSVerify(o.insecureSkipTLSVerify),
action.WithPlainHTTP(o.plainHTTP),
action.WithPushOptWriter(out))
action.WithPushOptWriter(out),
action.WithSubject(o.subject))
client.Settings = settings
output, err := client.Run(chartRef, remote)
if err != nil {
@ -102,6 +104,7 @@ func newPushCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
f.BoolVar(&o.plainHTTP, "plain-http", false, "use insecure HTTP connections for the chart upload")
f.StringVar(&o.username, "username", "", "chart repository username where to locate the requested chart")
f.StringVar(&o.password, "password", "", "chart repository password where to locate the requested chart")
f.StringVar(&o.subject, "subject", "", "associate the chart with an existing artifact in the same repository via the OCI Referrers API (digest format: sha256:...)")
return cmd
}

@ -26,6 +26,9 @@ import (
"strings"
"time"
"github.com/opencontainers/go-digest"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"helm.sh/helm/v4/internal/tlsutil"
"helm.sh/helm/v4/pkg/chart/v2/loader"
"helm.sh/helm/v4/pkg/registry"
@ -93,6 +96,17 @@ func (pusher *OCIPusher) push(chartRef, href string) error {
chartArchiveFileCreatedTime := stat.ModTime()
pushOpts = append(pushOpts, registry.PushOptCreationTime(chartArchiveFileCreatedTime.Format(time.RFC3339)))
// Add subject for OCI Referrers API if specified. Validate the digest here;
// the registry client resolves it to a full descriptor against the target
// repository before attaching it to the manifest.
if pusher.opts.subject != "" {
subjectDigest, err := digest.Parse(pusher.opts.subject)
if err != nil {
return fmt.Errorf("invalid --subject %q (expected a digest, e.g. sha256:...): %w", pusher.opts.subject, err)
}
pushOpts = append(pushOpts, registry.PushOptSubject(&ocispec.Descriptor{Digest: subjectDigest}))
}
_, err = client.Push(chartBytes, ref, pushOpts...)
return err
}

@ -288,6 +288,14 @@ func TestOCIPusher_Push_ChartOperations(t *testing.T) {
expectError: true, // Will fail at the registry push step
errorContains: "", // Error depends on registry client behavior
},
{
name: "invalid subject digest",
chartRef: chartPath,
href: "oci://localhost:5000/test",
options: []Option{WithSubject("not-a-digest")},
expectError: true,
errorContains: "invalid --subject",
},
}
for _, tt := range tests {

@ -34,6 +34,7 @@ type options struct {
caFile string
insecureSkipTLSVerify bool
plainHTTP bool
subject string
}
// Option allows specifying various settings configurable by the user for overriding the defaults
@ -69,6 +70,14 @@ func WithPlainHTTP(plainHTTP bool) Option {
}
}
// WithSubject sets the subject digest for OCI Referrers API.
// When set, the pushed chart will be associated with the specified image digest.
func WithSubject(subject string) Option {
return func(opts *options) {
opts.subject = subject
}
}
// Pusher is an interface to support upload to the specified URL.
type Pusher interface {
// Push file content by url string

@ -28,6 +28,7 @@ import (
"net/http"
"net/url"
"os"
"path"
"sort"
"strings"
@ -577,10 +578,30 @@ func (c *Client) Pull(ref string, options ...PullOption) (*PullResult, error) {
allowedMediaTypes = append(allowedMediaTypes, ProvLayerMediaType)
}
// Disambiguate multi-chart Image Indexes by the requested chart name (the
// last path segment of the reference), matched against the chart manifest's
// org.opencontainers.image.title annotation. An index resolving to a single
// chart needs no selector and is unaffected.
var selectors map[string]string
if parsed, perr := newReference(ref); perr == nil {
if name := path.Base(parsed.Repository); name != "" && name != "." && name != "/" {
selectors = map[string]string{ocispec.AnnotationTitle: name}
// Version is a tie-breaker when several charts share the name. The tag
// stores "+" as "_" (see newReference); undo it to match the chart's
// own version annotation.
if parsed.Tag != "" {
selectors[ocispec.AnnotationVersion] = strings.ReplaceAll(parsed.Tag, "_", "+")
}
}
}
// 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,
Selectors: selectors,
})
if err != nil {
return nil, err
@ -638,6 +659,7 @@ type (
provData []byte
strictMode bool
creationTime string
subject *ocispec.Descriptor
}
)
@ -699,14 +721,6 @@ func (c *Client) Push(data []byte, ref string, options ...PushOption) (*PushResu
return layers[i].Digest < layers[j].Digest
})
ociAnnotations := generateOCIAnnotations(meta, operation.creationTime)
manifestDescriptor, err := c.tagManifest(ctx, memoryStore, configDescriptor,
layers, ociAnnotations, parsedRef)
if err != nil {
return nil, err
}
repository, err := remote.NewRepository(parsedRef.String())
if err != nil {
return nil, err
@ -716,6 +730,25 @@ func (c *Client) Push(data []byte, ref string, options ...PushOption) (*PushResu
ctx = withScopeHint(ctx, repository, auth.ActionPull, auth.ActionPush)
// Resolve the subject to a full descriptor (mediaType + size) against the
// target repository. A subject carrying only a digest is not a spec-valid
// descriptor and a Referrers-aware registry can reject the manifest.
if operation.subject != nil {
resolved, err := repository.Resolve(ctx, operation.subject.Digest.String())
if err != nil {
return nil, fmt.Errorf("unable to resolve subject %s: %w", operation.subject.Digest, err)
}
operation.subject = &resolved
}
ociAnnotations := generateOCIAnnotations(meta, operation.creationTime)
manifestDescriptor, err := c.tagManifest(ctx, memoryStore, configDescriptor,
layers, ociAnnotations, parsedRef, operation.subject)
if err != nil {
return nil, err
}
manifestDescriptor, err = oras.ExtendedCopy(ctx, memoryStore, parsedRef.String(), repository, parsedRef.String(), oras.DefaultExtendedCopyOptions)
if err != nil {
return nil, err
@ -776,6 +809,13 @@ func PushOptCreationTime(creationTime string) PushOption {
}
}
// PushOptSubject returns a function that sets the subject for Referrers API
func PushOptSubject(subject *ocispec.Descriptor) PushOption {
return func(operation *pushOperation) {
operation.subject = subject
}
}
// Tags provides a sorted list all semver compliant tags for a given repository
func (c *Client) Tags(ref string) ([]string, error) {
parsedReference, err := registry.ParseReference(ref)
@ -909,12 +949,15 @@ func (c *Client) ValidateReference(ref, version string, u *url.URL) (string, *ur
func (c *Client) tagManifest(ctx context.Context, memoryStore *memory.Store,
configDescriptor ocispec.Descriptor, layers []ocispec.Descriptor,
ociAnnotations map[string]string, parsedRef reference,
subject *ocispec.Descriptor,
) (ocispec.Descriptor, error) {
manifest := ocispec.Manifest{
Versioned: specs.Versioned{SchemaVersion: 2},
Config: configDescriptor,
Layers: layers,
Annotations: ociAnnotations,
Versioned: specs.Versioned{SchemaVersion: 2},
ArtifactType: ConfigMediaType,
Config: configDescriptor,
Layers: layers,
Annotations: ociAnnotations,
Subject: subject,
}
manifestData, err := json.Marshal(manifest)

@ -76,6 +76,10 @@ func (suite *HTTPRegistryClientTestSuite) Test_5_ImageIndex() {
suite.Require().NoError(err)
}
func (suite *HTTPRegistryClientTestSuite) Test_6_PushWithSubject() {
testPushWithSubject(&suite.TestRegistry)
}
func TestHTTPRegistryClientTestSuite(t *testing.T) {
suite.Run(t, new(HTTPRegistryClientTestSuite))
}

@ -46,7 +46,7 @@ func TestTagManifestTransformsReferences(t *testing.T) {
parsedRef, err := newReference(refWithPlus)
require.NoError(t, err)
desc, err := client.tagManifest(ctx, memStore, configDesc, layers, nil, parsedRef)
desc, err := client.tagManifest(ctx, memStore, configDesc, layers, nil, parsedRef, nil)
require.NoError(t, err)
transformedDesc, err := memStore.Resolve(ctx, expectedRef)

@ -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,10 +18,13 @@ package registry
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"slices"
"sort"
"strings"
"sync"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
@ -56,6 +59,15 @@ 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
// Selectors are descriptor annotations used to disambiguate when more than
// one manifest in an Image Index matches ArtifactType. A manifest matches
// only if its descriptor annotations contain every selector key with the
// same value. With a single matching manifest, selectors are not consulted.
Selectors map[string]string
}
// GenericPullResult contains the result of a generic pull operation
@ -83,6 +95,191 @@ 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, selectors map[string]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: collect every manifest whose artifactType matches. A chart is
// never platform-specific, so descriptors carrying a platform (container
// images) are skipped outright.
var candidates []ocispec.Descriptor
var availableTypes []string
var withoutArtifactType []ocispec.Descriptor
for _, manifest := range index.Manifests {
if manifest.Platform != nil {
continue
}
switch {
case manifest.ArtifactType == artifactType:
candidates = append(candidates, manifest)
case manifest.ArtifactType != "":
availableTypes = append(availableTypes, manifest.ArtifactType)
default:
// No artifactType set: keep for the config.mediaType fallback below.
withoutArtifactType = append(withoutArtifactType, manifest)
}
}
// Second pass: legacy indexes whose descriptors carry no artifactType. Fetch
// those manifests and match on config.mediaType instead. Cache each chart's
// config descriptor so a later identity lookup need not refetch the manifest.
configCache := map[string]ocispec.Descriptor{}
if len(candidates) == 0 {
for _, candidate := range withoutArtifactType {
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
}
if manifest.Config.MediaType == artifactType {
candidates = append(candidates, candidate)
configCache[candidate.Digest.String()] = manifest.Config
}
}
}
switch len(candidates) {
case 0:
return ocispec.Descriptor{}, fmt.Errorf(
"no manifest with artifactType %q found in image index; available types: %v",
artifactType, availableTypes)
case 1:
return candidates[0], nil
}
// More than one chart in the index: disambiguate by the requested chart name
// (required), then by the requested version as a tie-breaker. Returning the
// first match here is what silently delivered the wrong chart, so an
// unresolvable choice is reported rather than guessed.
resolved := make([]chartCandidate, 0, len(candidates))
for _, d := range candidates {
name, version := c.chartIdentity(ctx, repo, d, configCache)
resolved = append(resolved, chartCandidate{desc: d, name: name, version: version})
}
wantName := selectors[ocispec.AnnotationTitle]
if wantName == "" {
return ocispec.Descriptor{}, fmt.Errorf(
"image index holds %d charts and no chart name was given to disambiguate; candidates: %s",
len(resolved), describeCandidates(resolved))
}
var named []chartCandidate
for _, cand := range resolved {
if cand.name == wantName {
named = append(named, cand)
}
}
switch len(named) {
case 0:
return ocispec.Descriptor{}, fmt.Errorf(
"image index holds %d charts and none is named %q; candidates: %s",
len(resolved), wantName, describeCandidates(resolved))
case 1:
return named[0].desc, nil
}
// Several charts share the requested name: break the tie by version.
if wantVersion := selectors[ocispec.AnnotationVersion]; wantVersion != "" {
var versioned []chartCandidate
for _, cand := range named {
if cand.version == wantVersion {
versioned = append(versioned, cand)
}
}
if len(versioned) == 1 {
return versioned[0].desc, nil
}
}
return ocispec.Descriptor{}, fmt.Errorf(
"image index is ambiguous: %d charts named %q; specify a version; candidates: %s",
len(named), wantName, describeCandidates(named))
}
// chartCandidate pairs an index descriptor with the chart identity used to
// disambiguate it.
type chartCandidate struct {
desc ocispec.Descriptor
name string
version string
}
// chartIdentity returns a candidate's chart name and version, preferring the
// descriptor annotations and falling back to the chart config (Chart.yaml)
// referenced by the manifest when the annotations are absent (legacy indexes).
func (c *GenericClient) chartIdentity(ctx context.Context, repo *remote.Repository, desc ocispec.Descriptor, configCache map[string]ocispec.Descriptor) (name, version string) {
name = desc.Annotations[ocispec.AnnotationTitle]
version = desc.Annotations[ocispec.AnnotationVersion]
if name != "" {
return name, version
}
// Resolve the chart config descriptor, reusing the one captured during the
// legacy pass when present so the manifest is not fetched a second time.
config, ok := configCache[desc.Digest.String()]
if !ok {
manifestData, err := content.FetchAll(ctx, repo, desc)
if err != nil {
return name, version
}
var manifest ocispec.Manifest
if err := json.Unmarshal(manifestData, &manifest); err != nil {
return name, version
}
config = manifest.Config
}
configData, err := content.FetchAll(ctx, repo, config)
if err != nil {
return name, version
}
var meta struct {
Name string `json:"name"`
Version string `json:"version"`
}
if err := json.Unmarshal(configData, &meta); err == nil {
if name == "" {
name = meta.Name
}
if version == "" {
version = meta.Version
}
}
return name, version
}
// describeCandidates renders chart candidates as name:version (falling back to
// the digest) for disambiguation error messages.
func describeCandidates(candidates []chartCandidate) string {
parts := make([]string, 0, len(candidates))
for _, c := range candidates {
switch {
case c.name == "":
parts = append(parts, c.desc.Digest.String())
case c.version == "":
parts = append(parts, c.name)
default:
parts = append(parts, c.name+":"+c.version)
}
}
return strings.Join(parts, ", ")
}
// 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 +300,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, options.Selectors)
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 +329,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,498 @@
/*
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"
"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 == "/v2/testrepo/multichart/blobs/"+imageIndexDigest.String(),
path == "/v2/testrepo/multichart/manifests/"+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 == "/v2/testrepo/multichart/manifests/"+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 := "localhost:" + u.Port()
ref := host + "/testrepo/multichart:1.0.0"
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 == "/v2/testrepo/nohelm/blobs/"+imageIndexDigest.String(),
path == "/v2/testrepo/nohelm/manifests/"+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 := "localhost:" + u.Port()
ref := host + "/testrepo/nohelm:1.0.0"
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 := "localhost:" + u.Port()
ref := host + "/testrepo/singlechart:1.0.0"
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)
}
// testChart is a chart manifest plus its config and layer blobs, for building
// multi-chart Image Index test fixtures.
type testChart struct {
name, version string
configData []byte
layerData []byte
manifestBytes []byte
configDigest digest.Digest
layerDigest digest.Digest
manifestDigest digest.Digest
}
func newTestChart(name, version string) testChart {
configData := []byte(`{"name":"` + name + `","version":"` + version + `","apiVersion":"v2"}`)
layerData := []byte{0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
configDigest := computeDigest(configData)
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)
return testChart{
name: name, version: version,
configData: configData, layerData: layerData, manifestBytes: manifestBytes,
configDigest: configDigest, layerDigest: layerDigest, manifestDigest: computeDigest(manifestBytes),
}
}
func (c testChart) indexDescriptor() ocispec.Descriptor {
return ocispec.Descriptor{
MediaType: ocispec.MediaTypeImageManifest,
Digest: c.manifestDigest,
Size: int64(len(c.manifestBytes)),
ArtifactType: ChartArtifactType,
Annotations: map[string]string{
ocispec.AnnotationTitle: c.name,
ocispec.AnnotationVersion: c.version,
},
}
}
// legacyIndexDescriptor is an index entry without artifactType or annotations,
// as produced before this feature; selection must fall back to the chart config.
func (c testChart) legacyIndexDescriptor() ocispec.Descriptor {
return ocispec.Descriptor{
MediaType: ocispec.MediaTypeImageManifest,
Digest: c.manifestDigest,
Size: int64(len(c.manifestBytes)),
}
}
// serveMultiChartIndex serves an Image Index (by tag 1.0.0 and by digest) plus
// every chart's manifest, config and layer (matched by digest), so a pull can
// resolve and select from it. Matching is repo-prefix agnostic.
func serveMultiChartIndex(indexBytes []byte, indexDigest digest.Digest, charts ...testChart) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := r.URL.Path
switch {
case p == "/v2/":
w.WriteHeader(http.StatusOK)
return
case strings.Contains(p, indexDigest.Encoded()),
strings.Contains(p, "/manifests/") && !strings.Contains(p, "sha256:"):
w.Header().Set("Content-Type", ocispec.MediaTypeImageIndex)
w.Header().Set("Docker-Content-Digest", indexDigest.String())
w.WriteHeader(http.StatusOK)
_, _ = w.Write(indexBytes)
return
}
for _, c := range charts {
switch {
case strings.Contains(p, c.manifestDigest.Encoded()):
w.Header().Set("Content-Type", ocispec.MediaTypeImageManifest)
w.Header().Set("Docker-Content-Digest", c.manifestDigest.String())
w.WriteHeader(http.StatusOK)
_, _ = w.Write(c.manifestBytes)
return
case strings.Contains(p, c.configDigest.Encoded()):
w.Header().Set("Content-Type", ConfigMediaType)
w.Header().Set("Docker-Content-Digest", c.configDigest.String())
w.WriteHeader(http.StatusOK)
_, _ = w.Write(c.configData)
return
case strings.Contains(p, c.layerDigest.Encoded()):
w.Header().Set("Content-Type", ChartLayerMediaType)
w.Header().Set("Docker-Content-Digest", c.layerDigest.String())
w.WriteHeader(http.StatusOK)
_, _ = w.Write(c.layerData)
return
}
}
w.WriteHeader(http.StatusNotFound)
}))
}
func TestPullFromImageIndexSelectsByName(t *testing.T) {
alpha := newTestChart("alpha", "1.0.0")
beta := newTestChart("beta", "1.0.0")
index := ocispec.Index{
MediaType: ocispec.MediaTypeImageIndex,
// Order alpha, beta on purpose: selection must be by name, not position.
Manifests: []ocispec.Descriptor{alpha.indexDescriptor(), beta.indexDescriptor()},
}
indexBytes, _ := json.Marshal(index)
indexDigest := computeDigest(indexBytes)
s := serveMultiChartIndex(indexBytes, indexDigest, alpha, beta)
defer s.Close()
u, _ := url.Parse(s.URL)
host := "localhost:" + u.Port()
client, err := NewClient(ClientOptPlainHTTP())
require.NoError(t, err)
// Pulling the "beta" reference must return beta even though alpha is first.
result, err := client.Pull(host + "/testrepo/beta:1.0.0")
require.NoError(t, err)
assert.Equal(t, "beta", result.Chart.Meta.Name)
// And the "alpha" reference must return alpha from the same index.
result, err = client.Pull(host + "/testrepo/alpha:1.0.0")
require.NoError(t, err)
assert.Equal(t, "alpha", result.Chart.Meta.Name)
}
func TestPullFromImageIndexAmbiguousName(t *testing.T) {
alpha := newTestChart("alpha", "1.0.0")
beta := newTestChart("beta", "1.0.0")
index := ocispec.Index{
MediaType: ocispec.MediaTypeImageIndex,
Manifests: []ocispec.Descriptor{alpha.indexDescriptor(), beta.indexDescriptor()},
}
indexBytes, _ := json.Marshal(index)
indexDigest := computeDigest(indexBytes)
s := serveMultiChartIndex(indexBytes, indexDigest, alpha, beta)
defer s.Close()
u, _ := url.Parse(s.URL)
host := "localhost:" + u.Port()
client, err := NewClient(ClientOptPlainHTTP())
require.NoError(t, err)
// The "gamma" reference matches neither chart: the wrong chart must not be
// returned silently; the error lists the available candidates.
_, err = client.Pull(host + "/testrepo/gamma:1.0.0")
require.Error(t, err)
assert.Contains(t, err.Error(), "none is named")
assert.Contains(t, err.Error(), "alpha")
assert.Contains(t, err.Error(), "beta")
}
func TestPullFromImageIndexSelectsByVersion(t *testing.T) {
v1 := newTestChart("app", "1.0.0")
v2 := newTestChart("app", "2.0.0")
index := ocispec.Index{
MediaType: ocispec.MediaTypeImageIndex,
// Same name, different versions: the requested version breaks the tie.
Manifests: []ocispec.Descriptor{v1.indexDescriptor(), v2.indexDescriptor()},
}
indexBytes, _ := json.Marshal(index)
indexDigest := computeDigest(indexBytes)
s := serveMultiChartIndex(indexBytes, indexDigest, v1, v2)
defer s.Close()
u, _ := url.Parse(s.URL)
host := "localhost:" + u.Port()
client, err := NewClient(ClientOptPlainHTTP())
require.NoError(t, err)
result, err := client.Pull(host + "/testrepo/app:2.0.0")
require.NoError(t, err)
assert.Equal(t, "app", result.Chart.Meta.Name)
assert.Equal(t, "2.0.0", result.Chart.Meta.Version)
}
func TestPullFromImageIndexLegacySelectsByName(t *testing.T) {
// Legacy index: entries carry neither artifactType nor title annotations, so
// selection falls back to config.mediaType and the chart's own Chart.yaml.
alpha := newTestChart("alpha", "1.0.0")
beta := newTestChart("beta", "1.0.0")
index := ocispec.Index{
MediaType: ocispec.MediaTypeImageIndex,
Manifests: []ocispec.Descriptor{alpha.legacyIndexDescriptor(), beta.legacyIndexDescriptor()},
}
indexBytes, _ := json.Marshal(index)
indexDigest := computeDigest(indexBytes)
s := serveMultiChartIndex(indexBytes, indexDigest, alpha, beta)
defer s.Close()
u, _ := url.Parse(s.URL)
host := "localhost:" + u.Port()
client, err := NewClient(ClientOptPlainHTTP())
require.NoError(t, err)
result, err := client.Pull(host + "/testrepo/beta:1.0.0")
require.NoError(t, err)
assert.Equal(t, "beta", result.Chart.Meta.Name)
}

@ -58,6 +58,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
@ -66,6 +67,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

@ -19,6 +19,7 @@ package registry
import (
"bytes"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net"
@ -35,9 +36,12 @@ import (
_ "github.com/distribution/distribution/v3/registry/auth/htpasswd"
_ "github.com/distribution/distribution/v3/registry/auth/token"
_ "github.com/distribution/distribution/v3/registry/storage/driver/inmemory"
"github.com/opencontainers/go-digest"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/stretchr/testify/suite"
"golang.org/x/crypto/bcrypt"
"oras.land/oras-go/v2/content"
"oras.land/oras-go/v2/registry/remote"
"helm.sh/helm/v4/internal/tlsutil"
)
@ -468,12 +472,12 @@ func testPush(suite *TestRegistry) {
suite.Equal(ref, result.Ref)
suite.Equal(meta.Name, result.Chart.Meta.Name)
suite.Equal(meta.Version, result.Chart.Meta.Version)
suite.Equal(int64(742), result.Manifest.Size)
suite.Equal(int64(800), result.Manifest.Size)
suite.Equal(int64(99), result.Config.Size)
suite.Equal(int64(973), result.Chart.Size)
suite.Equal(int64(695), result.Prov.Size)
suite.Equal(
"sha256:fbbade96da6050f68f94f122881e3b80051a18f13ab5f4081868dd494538f5c2",
"sha256:bc20397d31b1236b50d506e960b7ea81137712a88d084d3bddeb18a386797af9",
result.Manifest.Digest)
suite.Equal(
"sha256:8d17cb6bf6ccd8c29aace9a658495cbd5e2e87fc267876e86117c7db681c9580",
@ -486,6 +490,56 @@ func testPush(suite *TestRegistry) {
result.Prov.Digest)
}
func testPushWithSubject(suite *TestRegistry) {
creationTime := "1977-09-02T22:04:05Z"
chartData, err := os.ReadFile("../downloader/testdata/local-subchart-0.1.0.tgz")
suite.Require().NoError(err, "no error loading chart")
meta, err := extractChartMeta(chartData)
suite.Require().NoError(err)
// Referrers are per-repository, so the subject must already exist in the same
// repository. Push the chart once to create that manifest, then reference it.
repoRef := fmt.Sprintf("%s/testrepo/%s", suite.DockerRegistryHost, meta.Name)
subjectResult, err := suite.RegistryClient.Push(chartData, repoRef+":"+meta.Version, PushOptCreationTime(creationTime))
suite.Require().NoError(err, "no error pushing subject chart")
subjectDigest := subjectResult.Manifest.Digest
// Push again to a different tag, this time referring to the subject by digest.
ref := repoRef + ":withsubject"
_, err = suite.RegistryClient.Push(chartData, ref,
PushOptStrictMode(false),
PushOptCreationTime(creationTime),
PushOptSubject(&ocispec.Descriptor{Digest: digest.Digest(subjectDigest)}))
suite.Require().NoError(err, "no error pushing chart with subject")
// Fetch the pushed manifest and confirm the subject is a full descriptor
// (digest + mediaType + size), not the digest-only stub it used to be.
repo, err := remote.NewRepository(ref)
suite.Require().NoError(err)
repo.PlainHTTP = suite.RegistryClient.plainHTTP
repo.Client = suite.RegistryClient.authorizer
desc, rc, err := repo.FetchReference(suite.T().Context(), ref)
suite.Require().NoError(err)
defer rc.Close()
manifestBytes, err := content.ReadAll(rc, desc)
suite.Require().NoError(err)
var manifest ocispec.Manifest
suite.Require().NoError(json.Unmarshal(manifestBytes, &manifest))
suite.Require().NotNil(manifest.Subject, "pushed manifest must carry a subject")
suite.Equal(subjectDigest, manifest.Subject.Digest.String())
suite.NotEmpty(manifest.Subject.MediaType, "subject descriptor must carry a mediaType")
suite.Positive(manifest.Subject.Size, "subject descriptor must carry a size")
// A subject digest that does not exist in the repository is rejected.
_, err = suite.RegistryClient.Push(chartData, ref,
PushOptStrictMode(false),
PushOptCreationTime(creationTime),
PushOptSubject(&ocispec.Descriptor{Digest: digest.Digest("sha256:" + strings.Repeat("0", 64))}))
suite.Require().Error(err, "missing subject must error")
suite.Contains(err.Error(), "resolve subject")
}
func testPull(suite *TestRegistry) {
// bad/missing ref
ref := suite.DockerRegistryHost + "/testrepo/no-existy:1.2.3"
@ -541,12 +595,12 @@ func testPull(suite *TestRegistry) {
suite.Equal(ref, result.Ref)
suite.Equal(meta.Name, result.Chart.Meta.Name)
suite.Equal(meta.Version, result.Chart.Meta.Version)
suite.Equal(int64(742), result.Manifest.Size)
suite.Equal(int64(800), result.Manifest.Size)
suite.Equal(int64(99), result.Config.Size)
suite.Equal(int64(973), result.Chart.Size)
suite.Equal(int64(695), result.Prov.Size)
suite.Equal(
"sha256:fbbade96da6050f68f94f122881e3b80051a18f13ab5f4081868dd494538f5c2",
"sha256:bc20397d31b1236b50d506e960b7ea81137712a88d084d3bddeb18a386797af9",
result.Manifest.Digest)
suite.Equal(
"sha256:8d17cb6bf6ccd8c29aace9a658495cbd5e2e87fc267876e86117c7db681c9580",
@ -557,7 +611,7 @@ func testPull(suite *TestRegistry) {
suite.Equal(
"sha256:b0a02b7412f78ae93324d48df8fcc316d8482e5ad7827b5b238657a29a22f256",
result.Prov.Digest)
suite.JSONEq("{\"schemaVersion\":2,\"config\":{\"mediaType\":\"application/vnd.cncf.helm.config.v1+json\",\"digest\":\"sha256:8d17cb6bf6ccd8c29aace9a658495cbd5e2e87fc267876e86117c7db681c9580\",\"size\":99},\"layers\":[{\"mediaType\":\"application/vnd.cncf.helm.chart.provenance.v1.prov\",\"digest\":\"sha256:b0a02b7412f78ae93324d48df8fcc316d8482e5ad7827b5b238657a29a22f256\",\"size\":695},{\"mediaType\":\"application/vnd.cncf.helm.chart.content.v1.tar+gzip\",\"digest\":\"sha256:e5ef611620fb97704d8751c16bab17fedb68883bfb0edc76f78a70e9173f9b55\",\"size\":973}],\"annotations\":{\"org.opencontainers.image.created\":\"1977-09-02T22:04:05Z\",\"org.opencontainers.image.description\":\"A Helm chart for Kubernetes\",\"org.opencontainers.image.title\":\"signtest\",\"org.opencontainers.image.version\":\"0.1.0\"}}",
suite.JSONEq("{\"schemaVersion\":2,\"artifactType\":\"application/vnd.cncf.helm.config.v1+json\",\"config\":{\"mediaType\":\"application/vnd.cncf.helm.config.v1+json\",\"digest\":\"sha256:8d17cb6bf6ccd8c29aace9a658495cbd5e2e87fc267876e86117c7db681c9580\",\"size\":99},\"layers\":[{\"mediaType\":\"application/vnd.cncf.helm.chart.provenance.v1.prov\",\"digest\":\"sha256:b0a02b7412f78ae93324d48df8fcc316d8482e5ad7827b5b238657a29a22f256\",\"size\":695},{\"mediaType\":\"application/vnd.cncf.helm.chart.content.v1.tar+gzip\",\"digest\":\"sha256:e5ef611620fb97704d8751c16bab17fedb68883bfb0edc76f78a70e9173f9b55\",\"size\":973}],\"annotations\":{\"org.opencontainers.image.created\":\"1977-09-02T22:04:05Z\",\"org.opencontainers.image.description\":\"A Helm chart for Kubernetes\",\"org.opencontainers.image.title\":\"signtest\",\"org.opencontainers.image.version\":\"0.1.0\"}}",
string(result.Manifest.Data))
suite.JSONEq("{\"name\":\"signtest\",\"version\":\"0.1.0\",\"description\":\"A Helm chart for Kubernetes\",\"apiVersion\":\"v1\"}",
string(result.Config.Data))

Loading…
Cancel
Save