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