From b7e54e598bf5a41d16494d00d6d461e981e0641a Mon Sep 17 00:00:00 2001 From: Evan Anderson Date: Mon, 16 Jun 2025 14:53:37 -0700 Subject: [PATCH] Support Helm semver encoding in OCI repositories Signed-off-by: Evan Anderson Signed-off-by: Matheus Pimenta --- .../controller/ocirepository_controller.go | 46 ++++++++++++++++-- .../ocirepository_controller_test.go | 37 +++++++++++--- .../testdata/podinfo/podinfo-6.2.1.tar | Bin 0 -> 14848 bytes .../podinfo/podinfo-6.2.1_ref.1234567.tar | Bin 0 -> 14848 bytes 4 files changed, 70 insertions(+), 13 deletions(-) create mode 100644 internal/controller/testdata/podinfo/podinfo-6.2.1.tar create mode 100644 internal/controller/testdata/podinfo/podinfo-6.2.1_ref.1234567.tar diff --git a/internal/controller/ocirepository_controller.go b/internal/controller/ocirepository_controller.go index ebde8aa2d..c9600e402 100644 --- a/internal/controller/ocirepository_controller.go +++ b/internal/controller/ocirepository_controller.go @@ -40,6 +40,7 @@ import ( "github.com/google/go-containerregistry/pkg/v1/remote" "github.com/notaryproject/notation-go/verifier/trustpolicy" "github.com/sigstore/cosign/v3/pkg/cosign" + "helm.sh/helm/v4/pkg/registry" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" @@ -871,7 +872,7 @@ func (r *OCIRepositoryReconciler) getArtifactRef(obj *sourcev1.OCIRepository, op } if obj.Spec.Reference.SemVer != "" { - return r.getTagBySemver(repo, obj.Spec.Reference.SemVer, filterTags(obj.Spec.Reference.SemverFilter), options) + return r.getTagBySemver(repo, obj.Spec.Reference.SemVer, filterTags(obj.Spec.Reference.SemverFilter), obj.GetLayerMediaType(), options) } if obj.Spec.Reference.Tag != "" { @@ -884,7 +885,7 @@ func (r *OCIRepositoryReconciler) getArtifactRef(obj *sourcev1.OCIRepository, op // getTagBySemver call the remote container registry, fetches all the tags from the repository, // and returns the latest tag according to the semver expression. -func (r *OCIRepositoryReconciler) getTagBySemver(repo name.Repository, exp string, filter filterFunc, options []remote.Option) (name.Reference, error) { +func (r *OCIRepositoryReconciler) getTagBySemver(repo name.Repository, exp string, filter filterFunc, mediaType string, options []remote.Option) (name.Reference, error) { tags, err := remote.List(repo, options...) if err != nil { return nil, err @@ -901,8 +902,9 @@ func (r *OCIRepositoryReconciler) getTagBySemver(repo name.Repository, exp strin } var matchingVersions []*semver.Version - for _, t := range validTags { - v, err := version.ParseVersion(t) + for _, ociTag := range validTags { + semVerTag := convertOCIToSemVerTag(ociTag, mediaType) + v, err := version.ParseVersion(semVerTag) if err != nil { continue } @@ -916,8 +918,42 @@ func (r *OCIRepositoryReconciler) getTagBySemver(repo name.Repository, exp strin return nil, fmt.Errorf("no match found for semver: %s", exp) } + // Find the latest SemVer. sort.Sort(sort.Reverse(semver.Collection(matchingVersions))) - return repo.Tag(matchingVersions[0].Original()), nil + semVerTag := matchingVersions[0].Original() + + // Convert the latest SemVer to an OCI tag and return the reference. + ociTag := convertSemVerToOCITag(semVerTag, mediaType) + return repo.Tag(ociTag), nil +} + +// convertSemVerToOCITag converts a SemVer tag to an OCI tag +// according to rules defined by the media type. +// +// For OCI Helm charts, the conversion is mapping `+` to `_`, +// because `+` is not permitted in OCI tags, while `_` is not +// permitted in SemVer. Each character not being permitted in +// one of the two sides establishes a perfect bijection between, +// which then makes the mapping implemented by Helm (and honored +// here) completely safe. +func convertSemVerToOCITag(semVer, mediaType string) string { + if mediaType == registry.ChartLayerMediaType { + return strings.ReplaceAll(semVer, "+", "_") + } + return semVer +} + +// convertOCIToSemVerTag converts an OCI tag to a SemVer tag +// according to rules defined by the media type. +// +// For OCI Helm charts, the conversion is mapping `_` to `+`, +// see the comment above on convertSemVerToOCITag for the +// mapping in the opposite direction and rationale. +func convertOCIToSemVerTag(ociTag, mediaType string) string { + if mediaType == registry.ChartLayerMediaType { + return strings.ReplaceAll(ociTag, "_", "+") + } + return ociTag } // keychain generates the credential keychain based on the resource diff --git a/internal/controller/ocirepository_controller_test.go b/internal/controller/ocirepository_controller_test.go index 0755ff8c7..7c4dc7a01 100644 --- a/internal/controller/ocirepository_controller_test.go +++ b/internal/controller/ocirepository_controller_test.go @@ -43,6 +43,7 @@ import ( "github.com/notaryproject/notation-go/signer" "github.com/notaryproject/notation-go/verifier/trustpolicy" . "github.com/onsi/gomega" + "github.com/onsi/gomega/types" ocispec "github.com/opencontainers/image-spec/specs-go/v1" coptions "github.com/sigstore/cosign/v3/cmd/cosign/cli/options" "github.com/sigstore/cosign/v3/cmd/cosign/cli/sign" @@ -2892,6 +2893,8 @@ func TestOCIRepository_getArtifactRef(t *testing.T) { "6.1.5", "6.1.6-rc.1", "6.1.6", + "6.2.1_ref.1234567", // Version 6.2.1+ref.1234567, encoded as a tag + "6.2.1", // Version 6.2.1, same precedence as 6.2.1, per semver rule 10 ) g.Expect(err).ToNot(HaveOccurred()) @@ -2899,13 +2902,14 @@ func TestOCIRepository_getArtifactRef(t *testing.T) { name string url string reference *sourcev1.OCIRepositoryRef + selector *sourcev1.OCILayerSelector wantErr bool - want string + want types.GomegaMatcher }{ { name: "valid url with no reference", url: "oci://ghcr.io/stefanprodan/charts", - want: "ghcr.io/stefanprodan/charts:latest", + want: Equal("ghcr.io/stefanprodan/charts:latest"), }, { name: "valid url with tag reference", @@ -2913,7 +2917,7 @@ func TestOCIRepository_getArtifactRef(t *testing.T) { reference: &sourcev1.OCIRepositoryRef{ Tag: "6.1.6", }, - want: "ghcr.io/stefanprodan/charts:6.1.6", + want: Equal("ghcr.io/stefanprodan/charts:6.1.6"), }, { name: "valid url with digest reference", @@ -2921,15 +2925,29 @@ func TestOCIRepository_getArtifactRef(t *testing.T) { reference: &sourcev1.OCIRepositoryRef{ Digest: imgs["6.1.6"].digest.String(), }, - want: "ghcr.io/stefanprodan/charts@" + imgs["6.1.6"].digest.String(), + want: Equal("ghcr.io/stefanprodan/charts@" + imgs["6.1.6"].digest.String()), }, { name: "valid url with semver reference", url: fmt.Sprintf("oci://%s/podinfo", server.registryHost), reference: &sourcev1.OCIRepositoryRef{ - SemVer: ">= 6.1.6", + SemVer: "~6.1.x", + }, + want: Equal(server.registryHost + "/podinfo:6.1.6"), + }, + { + name: "valid url with semver reference and build identifier", + url: fmt.Sprintf("oci://%s/podinfo", server.registryHost), + reference: &sourcev1.OCIRepositoryRef{ + SemVer: ">= 6.2.0", }, - want: server.registryHost + "/podinfo:6.1.6", + selector: &sourcev1.OCILayerSelector{ + MediaType: "application/vnd.cncf.helm.chart.content.v1.tar+gzip", + }, + // Build info does not have a defined sort order in SemVer, so these + // two are equivalently new. + want: Or(Equal(server.registryHost+"/podinfo:6.2.1_ref.1234567"), + Equal(server.registryHost+"/podinfo:6.2.1")), }, { name: "invalid url without oci prefix", @@ -2943,7 +2961,7 @@ func TestOCIRepository_getArtifactRef(t *testing.T) { SemVer: ">= 6.1.x-0", SemverFilter: ".*-rc.*", }, - want: server.registryHost + "/podinfo:6.1.6-rc.1", + want: Equal(server.registryHost + "/podinfo:6.1.6-rc.1"), }, { name: "valid url with semver filter and unexisting version", @@ -2984,6 +3002,9 @@ func TestOCIRepository_getArtifactRef(t *testing.T) { if tt.reference != nil { obj.Spec.Reference = tt.reference } + if tt.selector != nil { + obj.Spec.LayerSelector = tt.selector + } opts := makeRemoteOptions(ctx, makeTransport(true), authn.DefaultKeychain, nil) got, err := r.getArtifactRef(obj, opts) @@ -2992,7 +3013,7 @@ func TestOCIRepository_getArtifactRef(t *testing.T) { return } g.Expect(err).ToNot(HaveOccurred()) - g.Expect(got.String()).To(Equal(tt.want)) + g.Expect(got.String()).To(tt.want) }) } } diff --git a/internal/controller/testdata/podinfo/podinfo-6.2.1.tar b/internal/controller/testdata/podinfo/podinfo-6.2.1.tar new file mode 100644 index 0000000000000000000000000000000000000000..09616c2dfabc3ca74b251763d2de655d892e2629 GIT binary patch literal 14848 zcmeHNZExE)5YFfP3PQzz4XADE{RPN}E$Pr;SskQF2dpRpTB2;OlBiL%Q8&nc-%)Ro zT_;XzHz-yK0U{soj>kLR@gYSo@;vIhB-F9%IuUkE98Mw&`_#r5$2z7_>>8o1o4V&< z2UFK`V$U>VOfBM?=ICJigRzBO+dcq=>RJEarB&~`YwN}VvbU?=onu}|BEY0wH7U#C zX$QBfzGss6c8&kLR~K(j$8R1o7wZ^1mesnZ@>J_8_-Qt6l?x4Q+xP(O9FvEPYTADA z`0K-G@p9ws{{MI8ft~aJpMqyw#b;D?DLzDj&hWh_3GxG<}{~L`(TA=MZ4YZe9 zC@7IsDT5&DgYxeZQU-2J{PsHj?g zm6C+Bw=!ax7Q`ee=u54o@wxnxNCk-2oR@OhzK?U7t?DU%jk6GpsQF)I3oXdzN%B%V zPGLmV>uE}+wW05LGEZsIwEV@zN0v=bSZ(dAOp0aBSq64hvg7QxIZIkqXJkT?TKu;3 zOz)4@AGce|N#DFj5A2%wql7G}7%B{X@AGi|*Bz)AYxqw$%^v^V>(6~Q3`ZYf1>`CF zO!ujFMVDICpSJ&l-KO2||5lv%jefMzA4N1z_%fxL94<+k?6E%;{|EMers3$T`LA0h zhWPjRZ;wH^M-|E0J1PpsvjCAiFGdUf5U@-Xpp$k!9Hvx~h)5C~BBan+fN~BY191N|6 z6o4eBOQjgJ@B~<cWN*zsT2nJNLDUso< z!e5Ot828CTcro3b+PnItxWC?};B%g^a2cS}_zjom5HctaEteWX+dD!{|1&A`mZxLrc{yU=^Sw?h zm?3${lK@>FpSSd`ssT7nu&`(wzf=i6?6f|taw_t$!`!;Dw$AtcPU~h>Ax~2RDbg|y z(6Di2hc*}}2OX^pcz`NuGpHjK{}mZ!UoF_RMG2Nouq9fskPcKH9yMvGCWLwvS3(yw zIV!WWB35Jp7V~rvQ2=a5 z(eUFMZEX%JwuFZynZbTDP!So;C`sh(b4QM4Ofr(3C=ay-NmZ-nku0UK*WAzsTVJS$ z-^C=wKpit8kN7$oKOsTw}akY-D^jTw0Rb#)vor<`M zH*2Js11ceaG7YFT00l1qs#XL`5h}c(`WiHlkXBW!EKPu7%92(Y)ARuKt84}QS6bV& zHmLGSvV?sufnUvs&jjn951jvCOLy1u-?W|n{Aa&GxJO$z|J$RVx78l=5zBjZ;&Plo zO`zgbZQxf=Th@*+IwuC*Eq=WMs#aL>?hXhxZNID4x!SF_yF9fsEM@Pu%w4|Do_~|d z3SyK@_jsu%_a5jy(0kzj=K<=5wxMG`_NWQBE?tLso)<^9t4ATGcw)f?P>*;x@=dH8 z*t1-V7{tfU1QR0)!{P4tf3RtEF&xA`CXY93*iL=;{s-LtUC)1AcX5CIx81RO;D8K1u5aUMdsz!0o8dT)Uy^B= z%^N>DHj}PszM55^A2p~IXX-jpDY?I!Nl*$t^hXVwCyRhs6%4+PGg@rs*qyp9{~_Dg y_W!zL_xZoO;~$~nMtfX_s#UwZBLK_xu9>Ob-uCJEh}iTjy$5;^^d9(@9{3LiHH=>X literal 0 HcmV?d00001 diff --git a/internal/controller/testdata/podinfo/podinfo-6.2.1_ref.1234567.tar b/internal/controller/testdata/podinfo/podinfo-6.2.1_ref.1234567.tar new file mode 100644 index 0000000000000000000000000000000000000000..09616c2dfabc3ca74b251763d2de655d892e2629 GIT binary patch literal 14848 zcmeHNZExE)5YFfP3PQzz4XADE{RPN}E$Pr;SskQF2dpRpTB2;OlBiL%Q8&nc-%)Ro zT_;XzHz-yK0U{soj>kLR@gYSo@;vIhB-F9%IuUkE98Mw&`_#r5$2z7_>>8o1o4V&< z2UFK`V$U>VOfBM?=ICJigRzBO+dcq=>RJEarB&~`YwN}VvbU?=onu}|BEY0wH7U#C zX$QBfzGss6c8&kLR~K(j$8R1o7wZ^1mesnZ@>J_8_-Qt6l?x4Q+xP(O9FvEPYTADA z`0K-G@p9ws{{MI8ft~aJpMqyw#b;D?DLzDj&hWh_3GxG<}{~L`(TA=MZ4YZe9 zC@7IsDT5&DgYxeZQU-2J{PsHj?g zm6C+Bw=!ax7Q`ee=u54o@wxnxNCk-2oR@OhzK?U7t?DU%jk6GpsQF)I3oXdzN%B%V zPGLmV>uE}+wW05LGEZsIwEV@zN0v=bSZ(dAOp0aBSq64hvg7QxIZIkqXJkT?TKu;3 zOz)4@AGce|N#DFj5A2%wql7G}7%B{X@AGi|*Bz)AYxqw$%^v^V>(6~Q3`ZYf1>`CF zO!ujFMVDICpSJ&l-KO2||5lv%jefMzA4N1z_%fxL94<+k?6E%;{|EMers3$T`LA0h zhWPjRZ;wH^M-|E0J1PpsvjCAiFGdUf5U@-Xpp$k!9Hvx~h)5C~BBan+fN~BY191N|6 z6o4eBOQjgJ@B~<cWN*zsT2nJNLDUso< z!e5Ot828CTcro3b+PnItxWC?};B%g^a2cS}_zjom5HctaEteWX+dD!{|1&A`mZxLrc{yU=^Sw?h zm?3${lK@>FpSSd`ssT7nu&`(wzf=i6?6f|taw_t$!`!;Dw$AtcPU~h>Ax~2RDbg|y z(6Di2hc*}}2OX^pcz`NuGpHjK{}mZ!UoF_RMG2Nouq9fskPcKH9yMvGCWLwvS3(yw zIV!WWB35Jp7V~rvQ2=a5 z(eUFMZEX%JwuFZynZbTDP!So;C`sh(b4QM4Ofr(3C=ay-NmZ-nku0UK*WAzsTVJS$ z-^C=wKpit8kN7$oKOsTw}akY-D^jTw0Rb#)vor<`M zH*2Js11ceaG7YFT00l1qs#XL`5h}c(`WiHlkXBW!EKPu7%92(Y)ARuKt84}QS6bV& zHmLGSvV?sufnUvs&jjn951jvCOLy1u-?W|n{Aa&GxJO$z|J$RVx78l=5zBjZ;&Plo zO`zgbZQxf=Th@*+IwuC*Eq=WMs#aL>?hXhxZNID4x!SF_yF9fsEM@Pu%w4|Do_~|d z3SyK@_jsu%_a5jy(0kzj=K<=5wxMG`_NWQBE?tLso)<^9t4ATGcw)f?P>*;x@=dH8 z*t1-V7{tfU1QR0)!{P4tf3RtEF&xA`CXY93*iL=;{s-LtUC)1AcX5CIx81RO;D8K1u5aUMdsz!0o8dT)Uy^B= z%^N>DHj}PszM55^A2p~IXX-jpDY?I!Nl*$t^hXVwCyRhs6%4+PGg@rs*qyp9{~_Dg y_W!zL_xZoO;~$~nMtfX_s#UwZBLK_xu9>Ob-uCJEh}iTjy$5;^^d9(@9{3LiHH=>X literal 0 HcmV?d00001