diff --git a/pkg/registry/client.go b/pkg/registry/client.go index 2257f189ca8..f9569b62b39 100644 --- a/pkg/registry/client.go +++ b/pkg/registry/client.go @@ -551,7 +551,9 @@ func (c *Client) Push(data []byte, ref string, options ...PushOption) (*PushResu descriptors = append(descriptors, provDescriptor) } - manifestData, manifest, err := content.GenerateManifest(&configDescriptor, nil, descriptors...) + ociAnnotations := generateOCIAnnotations(meta) + + manifestData, manifest, err := content.GenerateManifest(&configDescriptor, ociAnnotations, descriptors...) if err != nil { return nil, err } diff --git a/pkg/registry/util.go b/pkg/registry/util.go index 2a27543780e..a4b90c80ccf 100644 --- a/pkg/registry/util.go +++ b/pkg/registry/util.go @@ -25,6 +25,7 @@ import ( "strings" "github.com/Masterminds/semver/v3" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" "github.com/pkg/errors" "github.com/sirupsen/logrus" orascontext "oras.land/oras-go/pkg/context" @@ -35,6 +36,11 @@ import ( "helm.sh/helm/v3/pkg/chart/loader" ) +var immutableOciAnnotations = []string{ + ocispec.AnnotationVersion, + ocispec.AnnotationTitle, +} + // IsOCI determines whether or not a URL is to be treated as an OCI URL func IsOCI(url string) bool { return strings.HasPrefix(url, fmt.Sprintf("%s://", OCIScheme)) @@ -155,3 +161,80 @@ func NewRegistryClientWithTLS(out io.Writer, certFile, keyFile, caFile string, i } return registryClient, nil } + +// generateOCIAnnotations will generate OCI annotations to include within the OCI manifest +func generateOCIAnnotations(meta *chart.Metadata) map[string]string { + + // Get annotations from Chart attributes + ociAnnotations := generateChartOCIAnnotations(meta) + + // Copy Chart annotations +annotations: + for chartAnnotationKey, chartAnnotationValue := range meta.Annotations { + + // Avoid overriding key properties + for _, immutableOciKey := range immutableOciAnnotations { + if immutableOciKey == chartAnnotationKey { + continue annotations + } + } + + // Add chart annotation + ociAnnotations[chartAnnotationKey] = chartAnnotationValue + } + + return ociAnnotations +} + +// getChartOCIAnnotations will generate OCI annotations from the provided chart +func generateChartOCIAnnotations(meta *chart.Metadata) map[string]string { + chartOCIAnnotations := map[string]string{} + + chartOCIAnnotations = addToMap(chartOCIAnnotations, ocispec.AnnotationDescription, meta.Description) + chartOCIAnnotations = addToMap(chartOCIAnnotations, ocispec.AnnotationTitle, meta.Name) + chartOCIAnnotations = addToMap(chartOCIAnnotations, ocispec.AnnotationVersion, meta.Version) + chartOCIAnnotations = addToMap(chartOCIAnnotations, ocispec.AnnotationURL, meta.Home) + + if len(meta.Sources) > 0 { + chartOCIAnnotations = addToMap(chartOCIAnnotations, ocispec.AnnotationSource, meta.Sources[0]) + } + + if meta.Maintainers != nil && len(meta.Maintainers) > 0 { + var maintainerSb strings.Builder + + for maintainerIdx, maintainer := range meta.Maintainers { + + if len(maintainer.Name) > 0 { + maintainerSb.WriteString(maintainer.Name) + } + + if len(maintainer.Email) > 0 { + maintainerSb.WriteString(" (") + maintainerSb.WriteString(maintainer.Email) + maintainerSb.WriteString(")") + } + + if maintainerIdx < len(meta.Maintainers)-1 { + maintainerSb.WriteString(", ") + } + + } + + chartOCIAnnotations = addToMap(chartOCIAnnotations, ocispec.AnnotationAuthors, maintainerSb.String()) + + } + + return chartOCIAnnotations +} + +// addToMap takes an existing map and adds an item if the value is not empty +func addToMap(inputMap map[string]string, newKey string, newValue string) map[string]string { + + // Add item to map if its + if len(strings.TrimSpace(newValue)) > 0 { + inputMap[newKey] = newValue + } + + return inputMap + +} diff --git a/pkg/registry/util_test.go b/pkg/registry/util_test.go new file mode 100644 index 00000000000..3066257ade0 --- /dev/null +++ b/pkg/registry/util_test.go @@ -0,0 +1,216 @@ +/* +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 "helm.sh/helm/v3/pkg/registry" + +import ( + "reflect" + "testing" + + "helm.sh/helm/v3/pkg/chart" +) + +func TestGenerateOCIChartAnnotations(t *testing.T) { + + tests := []struct { + name string + chart *chart.Metadata + expect map[string]string + }{ + { + "Baseline chart", + &chart.Metadata{ + Name: "oci", + Version: "0.0.1", + }, + map[string]string{ + "org.opencontainers.image.title": "oci", + "org.opencontainers.image.version": "0.0.1", + }, + }, + { + "Simple chart values", + &chart.Metadata{ + Name: "oci", + Version: "0.0.1", + Description: "OCI Helm Chart", + Home: "https://helm.sh", + }, + map[string]string{ + "org.opencontainers.image.title": "oci", + "org.opencontainers.image.version": "0.0.1", + "org.opencontainers.image.description": "OCI Helm Chart", + "org.opencontainers.image.url": "https://helm.sh", + }, + }, + { + "Maintainer without email", + &chart.Metadata{ + Name: "oci", + Version: "0.0.1", + Description: "OCI Helm Chart", + Home: "https://helm.sh", + Maintainers: []*chart.Maintainer{ + { + Name: "John Snow", + }, + }, + }, + map[string]string{ + "org.opencontainers.image.title": "oci", + "org.opencontainers.image.version": "0.0.1", + "org.opencontainers.image.description": "OCI Helm Chart", + "org.opencontainers.image.url": "https://helm.sh", + "org.opencontainers.image.authors": "John Snow", + }, + }, + { + "Maintainer with email", + &chart.Metadata{ + Name: "oci", + Version: "0.0.1", + Description: "OCI Helm Chart", + Home: "https://helm.sh", + Maintainers: []*chart.Maintainer{ + {Name: "John Snow", Email: "john@winterfell.com"}, + }, + }, + map[string]string{ + "org.opencontainers.image.title": "oci", + "org.opencontainers.image.version": "0.0.1", + "org.opencontainers.image.description": "OCI Helm Chart", + "org.opencontainers.image.url": "https://helm.sh", + "org.opencontainers.image.authors": "John Snow (john@winterfell.com)", + }, + }, + { + "Multiple Maintainers", + &chart.Metadata{ + Name: "oci", + Version: "0.0.1", + Description: "OCI Helm Chart", + Home: "https://helm.sh", + Maintainers: []*chart.Maintainer{ + {Name: "John Snow", Email: "john@winterfell.com"}, + {Name: "Jane Snow"}, + }, + }, + map[string]string{ + "org.opencontainers.image.title": "oci", + "org.opencontainers.image.version": "0.0.1", + "org.opencontainers.image.description": "OCI Helm Chart", + "org.opencontainers.image.url": "https://helm.sh", + "org.opencontainers.image.authors": "John Snow (john@winterfell.com), Jane Snow", + }, + }, + { + "Chart with Sources", + &chart.Metadata{ + Name: "oci", + Version: "0.0.1", + Description: "OCI Helm Chart", + Sources: []string{ + "https://github.com/helm/helm", + }, + }, + map[string]string{ + "org.opencontainers.image.title": "oci", + "org.opencontainers.image.version": "0.0.1", + "org.opencontainers.image.description": "OCI Helm Chart", + "org.opencontainers.image.source": "https://github.com/helm/helm", + }, + }, + } + + for _, tt := range tests { + + result := generateChartOCIAnnotations(tt.chart) + + if !reflect.DeepEqual(tt.expect, result) { + t.Errorf("%s: expected map %v, got %v", tt.name, tt.expect, result) + } + + } +} + +func TestGenerateOCIAnnotations(t *testing.T) { + + tests := []struct { + name string + chart *chart.Metadata + expect map[string]string + }{ + { + "Baseline chart", + &chart.Metadata{ + Name: "oci", + Version: "0.0.1", + }, + map[string]string{ + "org.opencontainers.image.title": "oci", + "org.opencontainers.image.version": "0.0.1", + }, + }, + { + "Simple chart values with custom Annotations", + &chart.Metadata{ + Name: "oci", + Version: "0.0.1", + Description: "OCI Helm Chart", + Annotations: map[string]string{ + "extrakey": "extravlue", + "anotherkey": "anothervalue", + }, + }, + map[string]string{ + "org.opencontainers.image.title": "oci", + "org.opencontainers.image.version": "0.0.1", + "org.opencontainers.image.description": "OCI Helm Chart", + "extrakey": "extravlue", + "anotherkey": "anothervalue", + }, + }, + { + "Verify Chart Name and Version cannot be overridden from annotations", + &chart.Metadata{ + Name: "oci", + Version: "0.0.1", + Description: "OCI Helm Chart", + Annotations: map[string]string{ + "org.opencontainers.image.title": "badchartname", + "org.opencontainers.image.version": "1.0.0", + "extrakey": "extravlue", + }, + }, + map[string]string{ + "org.opencontainers.image.title": "oci", + "org.opencontainers.image.version": "0.0.1", + "org.opencontainers.image.description": "OCI Helm Chart", + "extrakey": "extravlue", + }, + }, + } + + for _, tt := range tests { + + result := generateOCIAnnotations(tt.chart) + + if !reflect.DeepEqual(tt.expect, result) { + t.Errorf("%s: expected map %v, got %v", tt.name, tt.expect, result) + } + + } +} diff --git a/pkg/registry/utils_test.go b/pkg/registry/utils_test.go index aaabffde3d4..bdeacc71204 100644 --- a/pkg/registry/utils_test.go +++ b/pkg/registry/utils_test.go @@ -279,12 +279,12 @@ func testPush(suite *TestSuite) { suite.Equal(ref, result.Ref) suite.Equal(meta.Name, result.Chart.Meta.Name) suite.Equal(meta.Version, result.Chart.Meta.Version) - suite.Equal(int64(512), result.Manifest.Size) + suite.Equal(int64(684), 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:af4c20a1df1431495e673c14ecfa3a2ba24839a7784349d6787cd67957392e83", + "sha256:b57e8ffd938c43253f30afedb3c209136288e6b3af3b33473e95ea3b805888e6", result.Manifest.Digest) suite.Equal( "sha256:8d17cb6bf6ccd8c29aace9a658495cbd5e2e87fc267876e86117c7db681c9580", @@ -352,12 +352,12 @@ func testPull(suite *TestSuite) { suite.Equal(ref, result.Ref) suite.Equal(meta.Name, result.Chart.Meta.Name) suite.Equal(meta.Version, result.Chart.Meta.Version) - suite.Equal(int64(512), result.Manifest.Size) + suite.Equal(int64(684), 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:af4c20a1df1431495e673c14ecfa3a2ba24839a7784349d6787cd67957392e83", + "sha256:b57e8ffd938c43253f30afedb3c209136288e6b3af3b33473e95ea3b805888e6", result.Manifest.Digest) suite.Equal( "sha256:8d17cb6bf6ccd8c29aace9a658495cbd5e2e87fc267876e86117c7db681c9580", @@ -368,7 +368,7 @@ func testPull(suite *TestSuite) { suite.Equal( "sha256:b0a02b7412f78ae93324d48df8fcc316d8482e5ad7827b5b238657a29a22f256", result.Prov.Digest) - suite.Equal("{\"schemaVersion\":2,\"config\":{\"mediaType\":\"application/vnd.cncf.helm.config.v1+json\",\"digest\":\"sha256:8d17cb6bf6ccd8c29aace9a658495cbd5e2e87fc267876e86117c7db681c9580\",\"size\":99},\"layers\":[{\"mediaType\":\"application/vnd.cncf.helm.chart.provenance.v1.prov\",\"digest\":\"sha256:b0a02b7412f78ae93324d48df8fcc316d8482e5ad7827b5b238657a29a22f256\",\"size\":695},{\"mediaType\":\"application/vnd.cncf.helm.chart.content.v1.tar+gzip\",\"digest\":\"sha256:e5ef611620fb97704d8751c16bab17fedb68883bfb0edc76f78a70e9173f9b55\",\"size\":973}]}", + suite.Equal("{\"schemaVersion\":2,\"config\":{\"mediaType\":\"application/vnd.cncf.helm.config.v1+json\",\"digest\":\"sha256:8d17cb6bf6ccd8c29aace9a658495cbd5e2e87fc267876e86117c7db681c9580\",\"size\":99},\"layers\":[{\"mediaType\":\"application/vnd.cncf.helm.chart.provenance.v1.prov\",\"digest\":\"sha256:b0a02b7412f78ae93324d48df8fcc316d8482e5ad7827b5b238657a29a22f256\",\"size\":695},{\"mediaType\":\"application/vnd.cncf.helm.chart.content.v1.tar+gzip\",\"digest\":\"sha256:e5ef611620fb97704d8751c16bab17fedb68883bfb0edc76f78a70e9173f9b55\",\"size\":973}],\"annotations\":{\"org.opencontainers.image.description\":\"A Helm chart for Kubernetes\",\"org.opencontainers.image.title\":\"signtest\",\"org.opencontainers.image.version\":\"0.1.0\"}}", string(result.Manifest.Data)) suite.Equal("{\"name\":\"signtest\",\"version\":\"0.1.0\",\"description\":\"A Helm chart for Kubernetes\",\"apiVersion\":\"v1\"}", string(result.Config.Data))