From ae3c2777e59e2930e7728ae717aeb2bfca812a1f Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Tue, 2 Dec 2025 04:01:40 +0300 Subject: [PATCH] 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))