From 24ab25c82d2fe5595a22b3f7fe2cf7a5970003ad Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Sun, 28 Jun 2026 02:37:22 +0300 Subject: [PATCH] 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 Signed-off-by: Aleksei Sviridkin --- pkg/action/push.go | 10 + pkg/cmd/push.go | 5 +- pkg/pusher/ocipusher.go | 14 + pkg/pusher/ocipusher_test.go | 8 + pkg/pusher/pusher.go | 9 + pkg/registry/client.go | 67 ++++- pkg/registry/client_http_test.go | 4 + pkg/registry/client_test.go | 2 +- pkg/registry/constants.go | 4 + pkg/registry/generic.go | 219 +++++++++++++- pkg/registry/index_test.go | 498 +++++++++++++++++++++++++++++++ pkg/registry/plugin.go | 2 + pkg/registry/registry_test.go | 64 +++- 13 files changed, 886 insertions(+), 20 deletions(-) create mode 100644 pkg/registry/index_test.go 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 df0bad643..f919ae453 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 { @@ -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 } diff --git a/pkg/pusher/ocipusher.go b/pkg/pusher/ocipusher.go index 2a12e09b4..d7dd04358 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,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 } diff --git a/pkg/pusher/ocipusher_test.go b/pkg/pusher/ocipusher_test.go index b63077293..e480a1a2e 100644 --- a/pkg/pusher/ocipusher_test.go +++ b/pkg/pusher/ocipusher_test.go @@ -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 { 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 4c7b9a6c8..e7476be13 100644 --- a/pkg/registry/client.go +++ b/pkg/registry/client.go @@ -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) diff --git a/pkg/registry/client_http_test.go b/pkg/registry/client_http_test.go index 0a51b3347..12c3102da 100644 --- a/pkg/registry/client_http_test.go +++ b/pkg/registry/client_http_test.go @@ -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)) } diff --git a/pkg/registry/client_test.go b/pkg/registry/client_test.go index 21604ce7e..f9fffeab2 100644 --- a/pkg/registry/client_test.go +++ b/pkg/registry/client_test.go @@ -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) diff --git a/pkg/registry/constants.go b/pkg/registry/constants.go index df31a18c4..b0059d313 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..5aa4fd83e 100644 --- a/pkg/registry/generic.go +++ b/pkg/registry/generic.go @@ -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 diff --git a/pkg/registry/index_test.go b/pkg/registry/index_test.go new file mode 100644 index 000000000..02e413bcd --- /dev/null +++ b/pkg/registry/index_test.go @@ -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) +} diff --git a/pkg/registry/plugin.go b/pkg/registry/plugin.go index 959faaa7d..a14232ba6 100644 --- a/pkg/registry/plugin.go +++ b/pkg/registry/plugin.go @@ -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 diff --git a/pkg/registry/registry_test.go b/pkg/registry/registry_test.go index 40ebe9687..d100b11e0 100644 --- a/pkg/registry/registry_test.go +++ b/pkg/registry/registry_test.go @@ -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))