From 60172f8445e797ba528a1c3e1332ffb23e65861c Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Thu, 27 Nov 2025 00:41:32 +0300 Subject: [PATCH 1/3] 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 From 812eb526e0a2b8eb37b447a9c9f5cc0b02e0d627 Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Tue, 2 Dec 2025 03:53:13 +0300 Subject: [PATCH 2/3] feat(registry): set artifactType when pushing charts Per HIP-9999 and OCI Image Spec 1.1, set artifactType field in manifest to enable efficient artifact selection from OCI Image Index. Co-Authored-By: Claude Signed-off-by: Aleksei Sviridkin --- pkg/registry/client.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/pkg/registry/client.go b/pkg/registry/client.go index f706c5fc7..6b999c5bf 100644 --- a/pkg/registry/client.go +++ b/pkg/registry/client.go @@ -913,10 +913,11 @@ func (c *Client) tagManifest(ctx context.Context, memoryStore *memory.Store, ociAnnotations map[string]string, parsedRef reference) (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, } manifestData, err := json.Marshal(manifest) From ae3c2777e59e2930e7728ae717aeb2bfca812a1f Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Tue, 2 Dec 2025 04:01:40 +0300 Subject: [PATCH 3/3] feat(registry): add --subject flag for OCI Referrers API Allow associating a chart with a container image via OCI Referrers API. When pushing a chart with --subject, the manifest will include a subject field pointing to the specified digest. Example: helm push chart.tgz oci://registry/repo --subject sha256:abc... This enables workflows where Helm charts are published as referrers to container images, allowing registry clients to discover related artifacts through the OCI Referrers API. Co-Authored-By: Claude Signed-off-by: Aleksei Sviridkin --- pkg/action/push.go | 10 ++++++++++ pkg/cmd/push.go | 5 ++++- pkg/pusher/ocipusher.go | 11 +++++++++++ pkg/pusher/pusher.go | 9 +++++++++ pkg/registry/client.go | 14 ++++++++++++-- pkg/registry/client_test.go | 2 +- pkg/registry/registry_test.go | 10 +++++----- 7 files changed, 52 insertions(+), 9 deletions(-) diff --git a/pkg/action/push.go b/pkg/action/push.go index 0c7148f65..ee05ab4c2 100644 --- a/pkg/action/push.go +++ b/pkg/action/push.go @@ -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), }, } diff --git a/pkg/cmd/push.go b/pkg/cmd/push.go index f57a7c52f..ab2fa3399 100644 --- a/pkg/cmd/push.go +++ b/pkg/cmd/push.go @@ -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 { @@ -84,7 +85,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 { @@ -103,6 +105,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 chart with a container image via OCI Referrers API (digest format: sha256:...)") return cmd } diff --git a/pkg/pusher/ocipusher.go b/pkg/pusher/ocipusher.go index f03188391..9ef39fb5a 100644 --- a/pkg/pusher/ocipusher.go +++ b/pkg/pusher/ocipusher.go @@ -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,14 @@ 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 + if pusher.opts.subject != "" { + subjectDesc := &ocispec.Descriptor{ + Digest: digest.Digest(pusher.opts.subject), + } + pushOpts = append(pushOpts, registry.PushOptSubject(subjectDesc)) + } + _, err = client.Push(chartBytes, ref, pushOpts...) return err } diff --git a/pkg/pusher/pusher.go b/pkg/pusher/pusher.go index 8ce78b011..2fa5a4eee 100644 --- a/pkg/pusher/pusher.go +++ b/pkg/pusher/pusher.go @@ -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 diff --git a/pkg/registry/client.go b/pkg/registry/client.go index 6b999c5bf..f4fe3c9ff 100644 --- a/pkg/registry/client.go +++ b/pkg/registry/client.go @@ -639,6 +639,7 @@ type ( provData []byte strictMode bool creationTime string + subject *ocispec.Descriptor } ) @@ -703,7 +704,7 @@ func (c *Client) Push(data []byte, ref string, options ...PushOption) (*PushResu ociAnnotations := generateOCIAnnotations(meta, operation.creationTime) manifestDescriptor, err := c.tagManifest(ctx, memoryStore, configDescriptor, - layers, ociAnnotations, parsedRef) + layers, ociAnnotations, parsedRef, operation.subject) if err != nil { return nil, err } @@ -775,6 +776,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) @@ -910,7 +918,8 @@ func (c *Client) ValidateReference(ref, version string, u *url.URL) (string, *ur // tagManifest prepares and tags a manifest in memory storage func (c *Client) tagManifest(ctx context.Context, memoryStore *memory.Store, configDescriptor ocispec.Descriptor, layers []ocispec.Descriptor, - ociAnnotations map[string]string, parsedRef reference) (ocispec.Descriptor, error) { + ociAnnotations map[string]string, parsedRef reference, + subject *ocispec.Descriptor) (ocispec.Descriptor, error) { manifest := ocispec.Manifest{ Versioned: specs.Versioned{SchemaVersion: 2}, @@ -918,6 +927,7 @@ func (c *Client) tagManifest(ctx context.Context, memoryStore *memory.Store, Config: configDescriptor, Layers: layers, Annotations: ociAnnotations, + Subject: subject, } manifestData, err := json.Marshal(manifest) diff --git a/pkg/registry/client_test.go b/pkg/registry/client_test.go index 98a8b2ea3..2ab220ff2 100644 --- a/pkg/registry/client_test.go +++ b/pkg/registry/client_test.go @@ -45,7 +45,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) diff --git a/pkg/registry/registry_test.go b/pkg/registry/registry_test.go index d4921c50b..0eb8c06d0 100644 --- a/pkg/registry/registry_test.go +++ b/pkg/registry/registry_test.go @@ -278,12 +278,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", @@ -351,12 +351,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", @@ -367,7 +367,7 @@ func testPull(suite *TestRegistry) { suite.Equal( "sha256:b0a02b7412f78ae93324d48df8fcc316d8482e5ad7827b5b238657a29a22f256", result.Prov.Digest) - suite.Equal("{\"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.Equal("{\"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.Equal("{\"name\":\"signtest\",\"version\":\"0.1.0\",\"description\":\"A Helm chart for Kubernetes\",\"apiVersion\":\"v1\"}", string(result.Config.Data))