diff --git a/controlplane/kubeadm/api/v1alpha3/kubeadm_control_plane_webhook.go b/controlplane/kubeadm/api/v1alpha3/kubeadm_control_plane_webhook.go index 51524194c2da..323562ecab49 100644 --- a/controlplane/kubeadm/api/v1alpha3/kubeadm_control_plane_webhook.go +++ b/controlplane/kubeadm/api/v1alpha3/kubeadm_control_plane_webhook.go @@ -30,6 +30,7 @@ import ( "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/util/validation/field" "sigs.k8s.io/cluster-api/util" + "sigs.k8s.io/cluster-api/util/container" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/webhook" ) @@ -252,7 +253,7 @@ func (in *KubeadmControlPlane) validateCoreDNSImage() (allErrs field.ErrorList) return allErrs } // TODO: Remove when kubeadm types include OpenAPI validation - if !util.ImageTagIsValid(in.Spec.KubeadmConfigSpec.ClusterConfiguration.DNS.ImageTag) { + if !container.ImageTagIsValid(in.Spec.KubeadmConfigSpec.ClusterConfiguration.DNS.ImageTag) { allErrs = append( allErrs, field.Forbidden( @@ -320,7 +321,7 @@ func (in *KubeadmControlPlane) validateEtcd(prev *KubeadmControlPlane) (allErrs } // TODO: Remove when kubeadm types include OpenAPI validation - if in.Spec.KubeadmConfigSpec.ClusterConfiguration.Etcd.Local != nil && !util.ImageTagIsValid(in.Spec.KubeadmConfigSpec.ClusterConfiguration.Etcd.Local.ImageTag) { + if in.Spec.KubeadmConfigSpec.ClusterConfiguration.Etcd.Local != nil && !container.ImageTagIsValid(in.Spec.KubeadmConfigSpec.ClusterConfiguration.Etcd.Local.ImageTag) { allErrs = append( allErrs, field.Forbidden( diff --git a/controlplane/kubeadm/internal/workload_cluster.go b/controlplane/kubeadm/internal/workload_cluster.go index 76f1ba516737..0dcbc846f962 100644 --- a/controlplane/kubeadm/internal/workload_cluster.go +++ b/controlplane/kubeadm/internal/workload_cluster.go @@ -38,6 +38,7 @@ import ( controlplanev1 "sigs.k8s.io/cluster-api/controlplane/kubeadm/api/v1alpha3" "sigs.k8s.io/cluster-api/util" "sigs.k8s.io/cluster-api/util/certs" + containerutil "sigs.k8s.io/cluster-api/util/container" "sigs.k8s.io/cluster-api/util/patch" ctrlclient "sigs.k8s.io/controller-runtime/pkg/client" ) @@ -452,13 +453,13 @@ func (w *Workload) UpdateKubeProxyImageInfo(ctx context.Context, kcp *controlpla return nil } - newImageName, err := util.ModifyImageTag(container.Image, kcp.Spec.Version) + newImageName, err := containerutil.ModifyImageTag(container.Image, kcp.Spec.Version) if err != nil { return err } if kcp.Spec.KubeadmConfigSpec.ClusterConfiguration != nil && kcp.Spec.KubeadmConfigSpec.ClusterConfiguration.ImageRepository != "" { - newImageName, err = util.ModifyImageRepository(newImageName, kcp.Spec.KubeadmConfigSpec.ClusterConfiguration.ImageRepository) + newImageName, err = containerutil.ModifyImageRepository(newImageName, kcp.Spec.KubeadmConfigSpec.ClusterConfiguration.ImageRepository) if err != nil { return err } diff --git a/util/container/image.go b/util/container/image.go new file mode 100644 index 000000000000..50d4d0f09393 --- /dev/null +++ b/util/container/image.go @@ -0,0 +1,141 @@ +/* +Copyright 2020 The Kubernetes 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 container + +import ( + "fmt" + "path" + "regexp" + + // Import the crypto sha256 algorithm for the docker image parser to work + _ "crypto/sha256" + // Import the crypto/sha512 algorithm for the docker image parser to work with 384 and 512 sha hashes + _ "crypto/sha512" + + "github.com/docker/distribution/reference" + "github.com/pkg/errors" +) + +var ( + ociTagAllowedChars = regexp.MustCompile(`[^-a-zA-Z0-9_\.]`) +) + +// Image type represents the container image details +type Image struct { + Repository string + Name string + Tag string + Digest string +} + +// ImageFromString parses a docker image string into three parts: repo, tag and digest. +func ImageFromString(image string) (Image, error) { + named, err := reference.ParseNamed(image) + if err != nil { + return Image{}, fmt.Errorf("couldn't parse image name: %v", err) + } + + var repo, tag, digest string + _, nameOnly := path.Split(reference.Path(named)) + if nameOnly != "" { + // split out the part of the name after the last / + lenOfCompleteName := len(named.Name()) + repo = named.Name()[:lenOfCompleteName-len(nameOnly)-1] + } + + tagged, ok := named.(reference.Tagged) + if ok { + tag = tagged.Tag() + } + + digested, ok := named.(reference.Digested) + if ok { + digest = digested.Digest().String() + } + + return Image{Repository: repo, Name: nameOnly, Tag: tag, Digest: digest}, nil +} + +func (i Image) String() string { + // repo/name [ ":" tag ] [ "@" digest ] + ref := fmt.Sprintf("%s/%s", i.Repository, i.Name) + if i.Tag != "" { + ref = fmt.Sprintf("%s:%s", ref, i.Tag) + } + if i.Digest != "" { + ref = fmt.Sprintf("%s@%s", ref, i.Digest) + } + return ref +} + +// ModifyImageRepository takes an imageName (e.g., repository/image:tag), and returns an image name with updated repository +func ModifyImageRepository(imageName, repositoryName string) (string, error) { + image, err := ImageFromString(imageName) + if err != nil { + return "", errors.Wrap(err, "failed to parse image name") + } + nameUpdated, err := reference.WithName(path.Join(repositoryName, image.Name)) + if err != nil { + return "", errors.Wrap(err, "failed to update repository name") + } + if image.Tag != "" { + retagged, err := reference.WithTag(nameUpdated, image.Tag) + if err != nil { + return "", errors.Wrap(err, "failed to parse image tag") + } + return reference.FamiliarString(retagged), nil + } + return "", errors.New("image must be tagged") +} + +// ModifyImageTag takes an imageName (e.g., repository/image:tag), and returns an image name with updated tag +func ModifyImageTag(imageName, tagName string) (string, error) { + normalisedTagName := SemverToOCIImageTag(tagName) + + namedRef, err := reference.ParseNormalizedNamed(imageName) + if err != nil { + return "", errors.Wrap(err, "failed to parse image name") + } + // return error if images use digest as version instead of tag + if _, isCanonical := namedRef.(reference.Canonical); isCanonical { + return "", errors.New("image uses digest as version, cannot update tag ") + } + + // update the image tag with tagName + namedTagged, err := reference.WithTag(namedRef, normalisedTagName) + if err != nil { + return "", errors.Wrap(err, "failed to update image tag") + } + + return reference.FamiliarString(reference.TagNameOnly(namedTagged)), nil +} + +// ImageTagIsValid ensures that a given image tag is compliant with the OCI spec +func ImageTagIsValid(tagName string) bool { + return !ociTagAllowedChars.MatchString(tagName) +} + +// SemverToOCIImageTag is a helper function that replaces all +// non-allowed symbols in tag strings with underscores. +// Image tag can only contain lowercase and uppercase letters, digits, +// underscores, periods and dashes. +// Current usage is for CI images where all of symbols except '+' are valid, +// but function is for generic usage where input can't be always pre-validated. +// Taken from k8s.io/cmd/kubeadm/app/util +func SemverToOCIImageTag(version string) string { + return ociTagAllowedChars.ReplaceAllString(version, "_") +} diff --git a/util/container/image_test.go b/util/container/image_test.go new file mode 100644 index 000000000000..cccd370afaad --- /dev/null +++ b/util/container/image_test.go @@ -0,0 +1,220 @@ +/* +Copyright 2020 The Kubernetes 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 container + +import ( + "strings" + "testing" + + "github.com/docker/distribution/reference" + . "github.com/onsi/gomega" +) + +func TestParseImageName(t *testing.T) { + testCases := []struct { + name string + input string + repo string + imageName string + tag string + digest string + wantError bool + }{ + { + name: "input with path and tag", + input: "k8s.gcr.io/dev/coredns:1.6.2", + repo: "k8s.gcr.io/dev", + imageName: "coredns", + tag: "1.6.2", + wantError: false, + }, + { + name: "input with name only", + input: "example.com/root", + repo: "example.com", + imageName: "root", + wantError: false, + }, + { + name: "input with name and tag without path", + input: "example.com/root:tag", + repo: "example.com", + imageName: "root", + tag: "tag", + wantError: false, + }, + { + name: "input with name and digest without tag", + input: "example.com/root@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + repo: "example.com", + imageName: "root", + digest: "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + wantError: false, + }, + { + name: "input with path and name", + input: "example.com/user/repo", + repo: "example.com/user", + imageName: "repo", + wantError: false, + }, + { + name: "input with path, name and tag", + input: "example.com/user/repo:tag", + repo: "example.com/user", + imageName: "repo", + tag: "tag", + wantError: false, + }, + { + name: "input with path, name, tag and digest", + input: "example.com/user/repo:tag@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + repo: "example.com/user", + imageName: "repo", + tag: "tag", + digest: "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + wantError: false, + }, + { + name: "input with url with port", + input: "url:5000/repo", + repo: "url:5000", + imageName: "repo", + wantError: false, + }, + { + name: "input with url with port and tag", + input: "url:5000/repo:tag", + repo: "url:5000", + imageName: "repo", + tag: "tag", + wantError: false, + }, + { + name: "input with url with port and digest", + input: "url:5000/repo@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + repo: "url:5000", + imageName: "repo", + digest: "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + wantError: false, + }, + { + name: "input with invalid image name", + input: "url$#", + repo: "", + imageName: "", + tag: "", + wantError: true, + }, + } + for _, tc := range testCases { + g := NewWithT(t) + + t.Run(tc.name, func(t *testing.T) { + image, err := ImageFromString(tc.input) + if tc.wantError { + g.Expect(err).To(HaveOccurred()) + } else { + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(image.Repository).To(Equal(tc.repo)) + g.Expect(image.Name).To(Equal(tc.imageName)) + g.Expect(image.Tag).To(Equal(tc.tag)) + g.Expect(image.Digest).To(Equal(tc.digest)) + g.Expect(image.String()).To(Equal(tc.input)) + } + }) + } +} + +func TestModifyImageRepository(t *testing.T) { + const testRepository = "example.com/new" + + testCases := []struct { + name string + image string + repo string + want string + wantError bool + wantErrMessage string + }{ + { + name: "updates the repository of the image", + image: "example.com/subpaths/are/okay/image:1.17.3", + repo: testRepository, + want: "example.com/new/image:1.17.3", + wantError: false, + wantErrMessage: "", + }, + { + name: "errors if the repository name is too long", + image: "example.com/image:1.17.3", + repo: strings.Repeat("a", 255), + want: "", + wantError: true, + wantErrMessage: reference.ErrNameTooLong.Error(), + }, + { + name: "errors if the image name is not canonical", + image: "image:1.17.3", + repo: testRepository, + want: "", + wantError: true, + wantErrMessage: reference.ErrNameNotCanonical.Error(), + }, + { + name: "errors if the image name is not tagged", + image: "example.com/image", + repo: testRepository, + want: "", + wantError: true, + wantErrMessage: "image must be tagged", + }, + { + name: "errors if the image name is not valid", + image: "example.com/image:$@$(*", + repo: testRepository, + want: "", + wantError: true, + wantErrMessage: "failed to parse image name", + }, + } + for _, tc := range testCases { + g := NewWithT(t) + + t.Run(tc.name, func(t *testing.T) { + res, err := ModifyImageRepository(tc.image, tc.repo) + if tc.wantError { + g.Expect(err).To(HaveOccurred()) + g.Expect(err).To(MatchError(ContainSubstring(tc.wantErrMessage))) + } else { + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(res).To(Equal(tc.want)) + } + }) + } +} + +func TestModifyImageTag(t *testing.T) { + g := NewWithT(t) + t.Run("should ensure image is a docker compatible tag", func(t *testing.T) { + testTag := "v1.17.4+build1" + image := "example.com/image:1.17.3" + res, err := ModifyImageTag(image, testTag) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(res).To(Equal("example.com/image:v1.17.4_build1")) + }) +} diff --git a/util/util.go b/util/util.go index e281156d2417..efce5c79015e 100644 --- a/util/util.go +++ b/util/util.go @@ -22,14 +22,12 @@ import ( "fmt" "math" "math/rand" - "path" "regexp" "strconv" "strings" "time" "github.com/blang/semver" - "github.com/docker/distribution/reference" "github.com/pkg/errors" v1 "k8s.io/api/core/v1" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" @@ -40,6 +38,7 @@ import ( "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/version" clusterv1 "sigs.k8s.io/cluster-api/api/v1alpha3" + "sigs.k8s.io/cluster-api/util/container" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/apiutil" @@ -63,7 +62,6 @@ var ( rnd = rand.New(rand.NewSource(time.Now().UnixNano())) ErrNoCluster = fmt.Errorf("no %q label present", clusterv1.ClusterLabelName) ErrUnstructuredFieldNotFound = fmt.Errorf("field not found") - ociTagAllowedChars = regexp.MustCompile(`[^-a-zA-Z0-9_\.]`) kubeSemver = regexp.MustCompile(`^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)([-0-9a-zA-Z_\.+]*)?$`) ) @@ -125,54 +123,22 @@ func Ordinalize(n int) string { return fmt.Sprintf("%d%s", n, m[an%10]) } -// ModifyImageTag takes an imageName (e.g., repository/image:tag), and returns an image name with updated tag -func ModifyImageTag(imageName, tagName string) (string, error) { - normalisedTagName := SemverToOCIImageTag(tagName) - - namedRef, err := reference.ParseNormalizedNamed(imageName) - if err != nil { - return "", errors.Wrap(err, "failed to parse image name") - } - // return error if images use digest as version instead of tag - if _, isCanonical := namedRef.(reference.Canonical); isCanonical { - return "", errors.New("image uses digest as version, cannot update tag ") - } - - // update the image tag with tagName - namedTagged, err := reference.WithTag(namedRef, normalisedTagName) - if err != nil { - return "", errors.Wrap(err, "failed to update image tag") - } - - return reference.FamiliarString(reference.TagNameOnly(namedTagged)), nil -} - // ModifyImageRepository takes an imageName (e.g., repository/image:tag), and returns an image name with updated repository +// Deprecated: Please use the functions in util/container func ModifyImageRepository(imageName, repositoryName string) (string, error) { - namedRef, err := reference.ParseNamed(imageName) - if err != nil { - return "", errors.Wrap(err, "failed to parse image name") - } - _, nameOnly := path.Split(reference.Path(namedRef)) - nameUpdated, err := reference.WithName(path.Join(repositoryName, nameOnly)) - if err != nil { - return "", errors.Wrap(err, "failed to update repository name") - } - if tagged, ok := namedRef.(reference.NamedTagged); ok { - retagged, err := reference.WithTag(nameUpdated, tagged.Tag()) - if err != nil { - // this shouldn't be possible since we parsed it already above - return "", errors.Wrap(err, "failed to parse image tag") - } - return reference.FamiliarString(retagged), nil - } else { - return "", errors.New("image must be tagged") - } + return container.ModifyImageRepository(imageName, repositoryName) +} + +// ModifyImageTag takes an imageName (e.g., repository/image:tag), and returns an image name with updated tag +// Deprecated: Please use the functions in util/container +func ModifyImageTag(imageName, tagName string) (string, error) { + return container.ModifyImageTag(imageName, tagName) } // ImageTagIsValid ensures that a given image tag is compliant with the OCI spec +// Deprecated: Please use the functions in util/container func ImageTagIsValid(tagName string) bool { - return !ociTagAllowedChars.MatchString(tagName) + return container.ImageTagIsValid(tagName) } // GetMachinesForCluster returns a list of machines associated with the cluster. @@ -191,15 +157,16 @@ func GetMachinesForCluster(ctx context.Context, c client.Client, cluster *cluste return &machines, nil } -// SemVerToOCIImageTag is a helper function that replaces all +// SemverToOCIImageTag is a helper function that replaces all // non-allowed symbols in tag strings with underscores. // Image tag can only contain lowercase and uppercase letters, digits, // underscores, periods and dashes. // Current usage is for CI images where all of symbols except '+' are valid, // but function is for generic usage where input can't be always pre-validated. // Taken from k8s.io/cmd/kubeadm/app/util +// Deprecated: Please use the functions in util/container func SemverToOCIImageTag(version string) string { - return ociTagAllowedChars.ReplaceAllString(version, "_") + return container.SemverToOCIImageTag(version) } // GetControlPlaneMachines returns a slice containing control plane machines. diff --git a/util/util_test.go b/util/util_test.go index 4ad92303ca1e..9a438e9921bf 100644 --- a/util/util_test.go +++ b/util/util_test.go @@ -19,13 +19,10 @@ package util import ( "context" "fmt" - "strings" "testing" "github.com/blang/semver" . "github.com/onsi/gomega" - - "github.com/docker/distribution/reference" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" @@ -481,51 +478,6 @@ func TestGetMachinesForCluster(t *testing.T) { g.Expect(machines.Items[0].Labels[clusterv1.ClusterLabelName]).To(Equal(cluster.Name)) } -func TestModifyImageTag(t *testing.T) { - g := NewWithT(t) - t.Run("should ensure image is a docker compatible tag", func(t *testing.T) { - testTag := "v1.17.4+build1" - image := "example.com/image:1.17.3" - res, err := ModifyImageTag(image, testTag) - g.Expect(err).NotTo(HaveOccurred()) - g.Expect(res).To(Equal("example.com/image:v1.17.4_build1")) - }) -} - -func TestModifyImageRepository(t *testing.T) { - const testRepository = "example.com/new" - g := NewGomegaWithT(t) - t.Run("updates the repository of the image", func(t *testing.T) { - image := "example.com/subpaths/are/okay/image:1.17.3" - res, err := ModifyImageRepository(image, testRepository) - g.Expect(err).NotTo(HaveOccurred()) - g.Expect(res).To(Equal("example.com/new/image:1.17.3")) - }) - - t.Run("errors if the repository name is too long", func(t *testing.T) { - testRepository := strings.Repeat("a", 255) - image := "example.com/image:1.17.3" - _, err := ModifyImageRepository(image, testRepository) - g.Expect(err).To(MatchError(ContainSubstring(reference.ErrNameTooLong.Error()))) - }) - - t.Run("errors if the image name is not canonical", func(t *testing.T) { - image := "image:1.17.3" - _, err := ModifyImageRepository(image, testRepository) - g.Expect(err).To(MatchError(ContainSubstring(reference.ErrNameNotCanonical.Error()))) - }) - t.Run("errors if the image name is not tagged", func(t *testing.T) { - image := "example.com/image" - _, err := ModifyImageRepository(image, testRepository) - g.Expect(err).To(MatchError(ContainSubstring("image must be tagged"))) - }) - t.Run("errors if the image name is not valid", func(t *testing.T) { - image := "example.com/image:$@$(*" - _, err := ModifyImageRepository(image, testRepository) - g.Expect(err).To(MatchError(ContainSubstring("failed to parse image name"))) - }) -} - func TestEnsureOwnerRef(t *testing.T) { g := NewWithT(t)