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 <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
pull/31583/head
Aleksei Sviridkin 4 weeks ago
parent 812eb526e0
commit ae3c2777e5
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 {
@ -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
}

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

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

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

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

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

Loading…
Cancel
Save