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 750bb9715..f4fe3c9ff 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 @@ -637,6 +639,7 @@ type ( provData []byte strictMode bool creationTime string + subject *ocispec.Descriptor } ) @@ -701,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 } @@ -773,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) @@ -908,13 +918,16 @@ 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}, - 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) 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/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 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))