diff --git a/cmd/helm/pull_test.go b/cmd/helm/pull_test.go index ae70595f9..fe6a5a68e 100644 --- a/cmd/helm/pull_test.go +++ b/cmd/helm/pull_test.go @@ -183,15 +183,20 @@ func TestPullCmd(t *testing.T) { wantError: true, }, { - name: "Fail fetching OCI chart without version specified", - args: fmt.Sprintf("oci://%s/u/ocitestuser/oci-dependent-chart:0.1.0", ociSrv.RegistryURL), - wantErrorMsg: "Error: --version flag is explicitly required for OCI registries", - wantError: true, + name: "Fetching OCI chart without version option specified", + args: fmt.Sprintf("oci://%s/u/ocitestuser/oci-dependent-chart:0.1.0", ociSrv.RegistryURL), + expectFile: "./oci-dependent-chart-0.1.0.tgz", }, { - name: "Fail fetching OCI chart without version specified", - args: fmt.Sprintf("oci://%s/u/ocitestuser/oci-dependent-chart:0.1.0 --version 0.1.0", ociSrv.RegistryURL), - wantError: true, + name: "Fetching OCI chart with version specified", + args: fmt.Sprintf("oci://%s/u/ocitestuser/oci-dependent-chart:0.1.0 --version 0.1.0", ociSrv.RegistryURL), + expectFile: "./oci-dependent-chart-0.1.0.tgz", + }, + { + name: "Fail fetching OCI chart with version mismatch", + args: fmt.Sprintf("oci://%s/u/ocitestuser/oci-dependent-chart:0.2.0 --version 0.1.0", ociSrv.RegistryURL), + wantErrorMsg: "Error: chart reference and version mismatch: 0.2.0 is not 0.1.0", + wantError: true, }, } diff --git a/pkg/downloader/chart_downloader.go b/pkg/downloader/chart_downloader.go index a95894e00..2d2e22f68 100644 --- a/pkg/downloader/chart_downloader.go +++ b/pkg/downloader/chart_downloader.go @@ -143,7 +143,39 @@ func (c *ChartDownloader) DownloadTo(ref, version, dest string) (string, *proven func (c *ChartDownloader) getOciURI(ref, version string, u *url.URL) (*url.URL, error) { var tag string - var err error + + registryReference, err := registry.NewReference(u.Path) + if err != nil { + return nil, err + } + + if version == "" { + // Use OCI URI tag as default + version = registryReference.Tag + } else { + if registryReference.Tag != "" && registryReference.Tag != version { + return nil, errors.Errorf("chart reference and version mismatch: %s is not %s", version, registryReference.Tag) + } + } + + if registryReference.Digest != "" { + if registryReference.Tag == "" { + // Install by digest only + return u, nil + } + + // Validate the tag if it was specified + path := registryReference.Registry + "/" + registryReference.Repository + ":" + registryReference.Tag + desc, err := c.RegistryClient.Resolve(path) + if err != nil { + // The resource does not have to be tagged when digest is specified + return u, nil + } + if desc != nil && desc.Digest.String() != registryReference.Digest { + return nil, errors.Errorf("chart reference digest mismatch: %s is not %s", desc.Digest.String(), registryReference.Digest) + } + return u, nil + } // Evaluate whether an explicit version has been provided. Otherwise, determine version to use _, errSemVer := semver.NewVersion(version) @@ -169,7 +201,7 @@ func (c *ChartDownloader) getOciURI(ref, version string, u *url.URL) (*url.URL, } } - u.Path = fmt.Sprintf("%s:%s", u.Path, tag) + u.Path = fmt.Sprintf("%s/%s:%s", registryReference.Registry, registryReference.Repository, tag) return u, err } diff --git a/pkg/downloader/chart_downloader_test.go b/pkg/downloader/chart_downloader_test.go index 131e21306..1a741bc1b 100644 --- a/pkg/downloader/chart_downloader_test.go +++ b/pkg/downloader/chart_downloader_test.go @@ -53,6 +53,12 @@ func TestResolveChartRef(t *testing.T) { {name: "full URL, file", ref: "file:///foo-1.2.3.tgz", fail: true}, {name: "invalid", ref: "invalid-1.2.3", fail: true}, {name: "not found", ref: "nosuchthing/invalid-1.2.3", fail: true}, + {name: "ref with tag", ref: "oci://example.com/helm-charts/nginx:15.4.2", expect: "oci://example.com/helm-charts/nginx:15.4.2"}, + {name: "no repository", ref: "oci://", fail: true}, + {name: "oci ref", ref: "oci://example.com/helm-charts/nginx", version: "15.4.2", expect: "oci://example.com/helm-charts/nginx:15.4.2"}, + {name: "oci ref with sha256", ref: "oci://example.com/install/by/sha@sha256:d234555386402a5867ef0169fefe5486858b6d8d209eaf32fd26d29b16807fd6", version: "0.1.1", expect: "oci://example.com/install/by/sha@sha256:d234555386402a5867ef0169fefe5486858b6d8d209eaf32fd26d29b16807fd6"}, + {name: "oci ref with sha256 and version", ref: "oci://example.com/install/by/sha:0.1.1@sha256:d234555386402a5867ef0169fefe5486858b6d8d209eaf32fd26d29b16807fd6", version: "0.1.1", expect: "oci://example.com/install/by/sha:0.1.1@sha256:d234555386402a5867ef0169fefe5486858b6d8d209eaf32fd26d29b16807fd6"}, + {name: "oci ref with sha256 and version mismatch", ref: "oci://example.com/install/by/sha:0.1.1@sha256:d234555386402a5867ef0169fefe5486858b6d8d209eaf32fd26d29b16807fd6", version: "0.1.2", fail: true}, } c := ChartDownloader{ diff --git a/pkg/getter/ocigetter.go b/pkg/getter/ocigetter.go index 0547cdcbb..5b0522395 100644 --- a/pkg/getter/ocigetter.go +++ b/pkg/getter/ocigetter.go @@ -20,6 +20,7 @@ import ( "fmt" "net" "net/http" + "path" "strings" "sync" "time" @@ -58,6 +59,9 @@ func (g *OCIGetter) get(href string) (*bytes.Buffer, error) { ref := strings.TrimPrefix(href, fmt.Sprintf("%s://", registry.OCIScheme)) + if version := g.opts.version; version != "" && !strings.Contains(path.Base(ref), ":") { + ref = fmt.Sprintf("%s:%s", ref, version) + } var pullOpts []registry.PullOption requestingProv := strings.HasSuffix(ref, ".prov") if requestingProv { diff --git a/pkg/registry/client.go b/pkg/registry/client.go index 42f736816..32d2773a3 100644 --- a/pkg/registry/client.go +++ b/pkg/registry/client.go @@ -319,7 +319,7 @@ type ( // Pull downloads a chart from a registry func (c *Client) Pull(ref string, options ...PullOption) (*PullResult, error) { - parsedRef, err := parseReference(ref) + parsedRef, err := NewReference(ref) if err != nil { return nil, err } @@ -351,13 +351,13 @@ func (c *Client) Pull(ref string, options ...PullOption) (*PullResult, error) { } var descriptors, layers []ocispec.Descriptor - remotesResolver, err := c.resolver(parsedRef) + remotesResolver, err := c.resolver(parsedRef.OrasReference) if err != nil { return nil, err } registryStore := content.Registry{Resolver: remotesResolver} - manifest, err := oras.Copy(ctx(c.out, c.debug), registryStore, parsedRef.String(), memoryStore, "", + manifest, err := oras.Copy(ctx(c.out, c.debug), registryStore, parsedRef.OrasReference.String(), memoryStore, "", oras.WithPullEmptyNameAllowed(), oras.WithAllowedMediaTypes(allowedMediaTypes), oras.WithLayerDescriptors(func(l []ocispec.Descriptor) { @@ -419,7 +419,7 @@ func (c *Client) Pull(ref string, options ...PullOption) (*PullResult, error) { }, Chart: &DescriptorPullSummaryWithMeta{}, Prov: &DescriptorPullSummary{}, - Ref: parsedRef.String(), + Ref: parsedRef.OrasReference.String(), } var getManifestErr error if _, manifestData, ok := memoryStore.Get(manifest); !ok { @@ -535,7 +535,7 @@ type ( // Push uploads a chart to a registry. func (c *Client) Push(data []byte, ref string, options ...PushOption) (*PushResult, error) { - parsedRef, err := parseReference(ref) + parsedRef, err := NewReference(ref) if err != nil { return nil, err } @@ -590,16 +590,16 @@ func (c *Client) Push(data []byte, ref string, options ...PushOption) (*PushResu return nil, err } - if err := memoryStore.StoreManifest(parsedRef.String(), manifest, manifestData); err != nil { + if err := memoryStore.StoreManifest(parsedRef.OrasReference.String(), manifest, manifestData); err != nil { return nil, err } - remotesResolver, err := c.resolver(parsedRef) + remotesResolver, err := c.resolver(parsedRef.OrasReference) if err != nil { return nil, err } registryStore := content.Registry{Resolver: remotesResolver} - _, err = oras.Copy(ctx(c.out, c.debug), memoryStore, parsedRef.String(), registryStore, "", + _, err = oras.Copy(ctx(c.out, c.debug), memoryStore, parsedRef.OrasReference.String(), registryStore, "", oras.WithNameValidation(nil)) if err != nil { return nil, err @@ -620,7 +620,7 @@ func (c *Client) Push(data []byte, ref string, options ...PushOption) (*PushResu }, Chart: chartSummary, Prov: &descriptorPushSummary{}, // prevent nil references - Ref: parsedRef.String(), + Ref: parsedRef.OrasReference.String(), } if operation.provData != nil { result.Prov = &descriptorPushSummary{ @@ -630,7 +630,7 @@ func (c *Client) Push(data []byte, ref string, options ...PushOption) (*PushResu } fmt.Fprintf(c.out, "Pushed: %s\n", result.Ref) fmt.Fprintf(c.out, "Digest: %s\n", result.Manifest.Digest) - if strings.Contains(parsedRef.Reference, "_") { + if strings.Contains(parsedRef.OrasReference.Reference, "_") { fmt.Fprintf(c.out, "%s contains an underscore.\n", result.Ref) fmt.Fprint(c.out, registryUnderscoreMessage+"\n") } @@ -701,3 +701,23 @@ func (c *Client) Tags(ref string) ([]string, error) { return tags, nil } + +// Resolve a reference to a descriptor. +func (c *Client) Resolve(ref string) (*ocispec.Descriptor, error) { + ctx := context.Background() + parsedRef, err := NewReference(ref) + if err != nil { + return nil, err + } + if parsedRef.Registry == "" { + return nil, nil + } + + remotesResolver, err := c.resolver(parsedRef.OrasReference) + if err != nil { + return nil, err + } + + _, desc, err := remotesResolver.Resolve(ctx, ref) + return &desc, err +} diff --git a/pkg/registry/reference.go b/pkg/registry/reference.go new file mode 100644 index 000000000..09b99588b --- /dev/null +++ b/pkg/registry/reference.go @@ -0,0 +1,71 @@ +/* +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 ( + "strings" + + orasregistry "oras.land/oras-go/pkg/registry" +) + +type Reference struct { + OrasReference orasregistry.Reference + Registry string + Repository string + Tag string + Digest string +} + +// NewReference will parse and validate the reference, and clean tags when +// applicable tags are only cleaned when plus (+) signs are present, and are +// converted to underscores (_) before pushing +// See https://github.com/helm/helm/issues/10166 +func NewReference(raw string) (result Reference, err error) { + // Remove oci:// prefix if it is there + raw = strings.TrimPrefix(raw, OCIScheme+"://") + + // The sole possible reference modification is replacing plus (+) signs + // present in tags with underscores (_). To do this properly, we first + // need to identify a tag, and then pass it on to the reference parser + // NOTE: Passing immediately to the reference parser will fail since (+) + // signs are an invalid tag character, and simply replacing all plus (+) + // occurrences could invalidate other portions of the URI + lastIndex := strings.LastIndex(raw, "@") + if lastIndex >= 0 { + result.Digest = raw[(lastIndex + 1):] + raw = raw[:lastIndex] + } + parts := strings.Split(raw, ":") + if len(parts) > 1 && !strings.Contains(parts[len(parts)-1], "/") { + tag := parts[len(parts)-1] + + if tag != "" { + // Replace any plus (+) signs with known underscore (_) conversion + newTag := strings.ReplaceAll(tag, "+", "_") + raw = strings.ReplaceAll(raw, tag, newTag) + } + } + + result.OrasReference, err = orasregistry.ParseReference(raw) + if err != nil { + return result, err + } + result.Registry = result.OrasReference.Registry + result.Repository = result.OrasReference.Repository + result.Tag = result.OrasReference.Reference + return result, nil +} diff --git a/pkg/registry/reference_test.go b/pkg/registry/reference_test.go new file mode 100644 index 000000000..986c10edd --- /dev/null +++ b/pkg/registry/reference_test.go @@ -0,0 +1,87 @@ +/* +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 "testing" + +func verify(t *testing.T, actual Reference, registry, repository, tag, digest string) { + if registry != actual.OrasReference.Registry { + t.Errorf("Oras Reference registry expected %v actual %v", registry, actual.Registry) + } + if repository != actual.OrasReference.Repository { + t.Errorf("Oras Reference repository expected %v actual %v", repository, actual.Repository) + } + if tag != actual.OrasReference.Reference { + t.Errorf("Oras Reference reference expected %v actual %v", tag, actual.Tag) + } + if registry != actual.Registry { + t.Errorf("Registry expected %v actual %v", registry, actual.Registry) + } + if repository != actual.Repository { + t.Errorf("Repository expected %v actual %v", repository, actual.Repository) + } + if tag != actual.Tag { + t.Errorf("Tag expected %v actual %v", tag, actual.Tag) + } + if digest != actual.Digest { + t.Errorf("Digest expected %v actual %v", digest, actual.Digest) + } +} + +func TestNewReference(t *testing.T) { + actual, err := NewReference("registry.example.com/repository:1.0@sha256:c6841b3a895f1444a6738b5d04564a57e860ce42f8519c3be807fb6d9bee7888") + if err != nil { + t.Errorf("Unexpected error %v", err) + } + verify(t, actual, "registry.example.com", "repository", "1.0", "sha256:c6841b3a895f1444a6738b5d04564a57e860ce42f8519c3be807fb6d9bee7888") + + actual, err = NewReference("oci://registry.example.com/repository:1.0@sha256:c6841b3a895f1444a6738b5d04564a57e860ce42f8519c3be807fb6d9bee7888") + if err != nil { + t.Errorf("Unexpected error %v", err) + } + verify(t, actual, "registry.example.com", "repository", "1.0", "sha256:c6841b3a895f1444a6738b5d04564a57e860ce42f8519c3be807fb6d9bee7888") + + actual, err = NewReference("a/b:1@c") + if err != nil { + t.Errorf("Unexpected error %v", err) + } + verify(t, actual, "a", "b", "1", "c") + + actual, err = NewReference("a/b:@") + if err != nil { + t.Errorf("Unexpected error %v", err) + } + verify(t, actual, "a", "b", "", "") + + actual, err = NewReference("registry.example.com/repository:1.0+001") + if err != nil { + t.Errorf("Unexpected error %v", err) + } + verify(t, actual, "registry.example.com", "repository", "1.0_001", "") + + actual, err = NewReference("thing:1.0") + if err == nil { + t.Errorf("Expect error error %v", err) + } + verify(t, actual, "", "", "", "") + + actual, err = NewReference("registry.example.com/the/repository@sha256:c6841b3a895f1444a6738b5d04564a57e860ce42f8519c3be807fb6d9bee7888") + if err != nil { + t.Errorf("Unexpected error %v", err) + } + verify(t, actual, "registry.example.com", "the/repository", "", "sha256:c6841b3a895f1444a6738b5d04564a57e860ce42f8519c3be807fb6d9bee7888") +} diff --git a/pkg/registry/util.go b/pkg/registry/util.go index 727cdae03..44519de41 100644 --- a/pkg/registry/util.go +++ b/pkg/registry/util.go @@ -32,7 +32,6 @@ import ( "github.com/pkg/errors" "github.com/sirupsen/logrus" orascontext "oras.land/oras-go/pkg/context" - "oras.land/oras-go/pkg/registry" "helm.sh/helm/v3/internal/tlsutil" "helm.sh/helm/v3/pkg/chart" @@ -115,31 +114,6 @@ func ctx(out io.Writer, debug bool) context.Context { return ctx } -// parseReference will parse and validate the reference, and clean tags when -// applicable tags are only cleaned when plus (+) signs are present, and are -// converted to underscores (_) before pushing -// See https://github.com/helm/helm/issues/10166 -func parseReference(raw string) (registry.Reference, error) { - // The sole possible reference modification is replacing plus (+) signs - // present in tags with underscores (_). To do this properly, we first - // need to identify a tag, and then pass it on to the reference parser - // NOTE: Passing immediately to the reference parser will fail since (+) - // signs are an invalid tag character, and simply replacing all plus (+) - // occurrences could invalidate other portions of the URI - parts := strings.Split(raw, ":") - if len(parts) > 1 && !strings.Contains(parts[len(parts)-1], "/") { - tag := parts[len(parts)-1] - - if tag != "" { - // Replace any plus (+) signs with known underscore (_) conversion - newTag := strings.ReplaceAll(tag, "+", "_") - raw = strings.ReplaceAll(raw, tag, newTag) - } - } - - return registry.ParseReference(raw) -} - // NewRegistryClientWithTLS is a helper function to create a new registry client with TLS enabled. func NewRegistryClientWithTLS(out io.Writer, certFile, keyFile, caFile string, insecureSkipTLSverify bool, registryConfig string, debug bool) (*Client, error) { tlsConf, err := tlsutil.NewClientTLS(certFile, keyFile, caFile, insecureSkipTLSverify)