diff --git a/pkg/util/imageparser/lifted.go b/pkg/util/imageparser/lifted.go new file mode 100644 index 000000000..2fb80dbb7 --- /dev/null +++ b/pkg/util/imageparser/lifted.go @@ -0,0 +1,43 @@ +package imageparser + +import "regexp" + +/* +This code is directly lifted from the distribution codebase as they haven't been exported. + +For reference: https://github.com/distribution/distribution/blob/9329f6a62b67d5e06d50dc93997c7705a075fcd9/reference/regexp.go +*/ + +var ( + // match compiles the string to a regular expression. + match = regexp.MustCompile + // TagRegexp matches valid tag names. From docker/docker:graph/tags.go. + TagRegexp = match(`[\w][\w.-]{0,127}`) + + // anchoredTagRegexp matches valid tag names, anchored at the start and + // end of the matched string. + anchoredTagRegexp = anchored(TagRegexp) + + // DigestRegexp matches valid digests. + DigestRegexp = match(`[A-Za-z][A-Za-z0-9]*(?:[-_+.][A-Za-z][A-Za-z0-9]*)*[:][[:xdigit:]]{32,}`) + + // anchoredDigestRegexp matches valid digests, anchored at the start and + // end of the matched string. + anchoredDigestRegexp = anchored(DigestRegexp) +) + +// anchored anchors the regular expression by adding start and end delimiters. +func anchored(res ...*regexp.Regexp) *regexp.Regexp { + return match(`^` + expression(res...).String() + `$`) +} + +// expression defines a full expression, where each regular expression must +// follow the previous. +func expression(res ...*regexp.Regexp) *regexp.Regexp { + var s string + for _, re := range res { + s += re.String() + } + + return match(s) +} diff --git a/pkg/util/imageparser/parser.go b/pkg/util/imageparser/parser.go new file mode 100644 index 000000000..8f88166ba --- /dev/null +++ b/pkg/util/imageparser/parser.go @@ -0,0 +1,172 @@ +package imageparser + +import ( + _ "crypto/sha256" // initialize crypto/sha256 to enable sha256 algorithm + _ "crypto/sha512" // initialize crypto/sha512 to enable sha512 algorithm + "strings" + + "github.com/distribution/distribution/v3/reference" +) + +// Components make up a whole image. +// Basically we presume an image can be made of `[domain][:port][path][:tag][@sha256:digest]`, ie: +// fictional.registry.example:10443/karmada/karmada-controller-manager:v1.0.0 or +// fictional.registry.example:10443/karmada/karmada-controller-manager@sha256:50d858e0985ecc7f60418aaf0cc5ab587f42c2570a884095a9e8ccacd0f6545c +type Components struct { + // hostname is the prefix of referencing image, like "k8s.gcr.io". + // It may optionally be followed by a port number, like "fictional.registry.example:10443". + hostname string + // repository is the short name of referencing image, like "karmada-controller-manager". + // It may be made up of slash-separated components, like "karmada/karmada-controller-manager". + repository string + // tag is the tag of referencing image. ie: + // - latest + // - v1.19.1 + tag string + // digest is the digest of referencing image. ie: + // - sha256:50d858e0985ecc7f60418aaf0cc5ab587f42c2570a884095a9e8ccacd0f6545c + digest string +} + +// Hostname returns the hostname, as well known as image registry. +func (c *Components) Hostname() string { + return c.hostname +} + +// SetHostname sets the hostname. +func (c *Components) SetHostname(hostname string) { + c.hostname = hostname +} + +// RemoveHostname removes the hostname. +func (c *Components) RemoveHostname() { + c.hostname = "" +} + +// Repository returns the repository without hostname. +func (c *Components) Repository() string { + return c.repository +} + +// SetRepository sets the repository. +func (c *Components) SetRepository(repository string) { + c.repository = repository +} + +// RemoveRepository removes the repository. +func (c *Components) RemoveRepository() { + c.repository = "" +} + +// FullRepository returns the whole repository, including hostname if not empty. +func (c *Components) FullRepository() string { + if c.hostname == "" { + return c.repository + } + + return c.hostname + "/" + c.repository +} + +// String returns the full name of the image, including repository and tag(or digest). +func (c *Components) String() string { + if c.tag != "" { + return c.FullRepository() + ":" + c.tag + } else if c.digest != "" { + return c.FullRepository() + "@" + c.digest + } + + return c.FullRepository() +} + +// Tag returns the tag. +func (c *Components) Tag() string { + return c.tag +} + +// SetTag sets the tag. +func (c *Components) SetTag(tag string) { + c.tag = tag +} + +// RemoveTag removes the tag. +func (c *Components) RemoveTag() { + c.tag = "" +} + +// Digest returns the digest. +func (c *Components) Digest() string { + return c.digest +} + +// SetDigest sets the digest. +func (c *Components) SetDigest(digest string) { + c.digest = digest +} + +// RemoveDigest removes the digest. +func (c *Components) RemoveDigest() { + c.digest = "" +} + +// TagOrDigest returns image tag if not empty otherwise returns image digest. +func (c *Components) TagOrDigest() string { + if c.tag != "" { + return c.tag + } + + return c.digest +} + +// SetTagOrDigest sets image tag or digest by matching the input. +func (c *Components) SetTagOrDigest(input string) { + if anchoredTagRegexp.MatchString(input) { + c.SetTag(input) + return + } + + if anchoredDigestRegexp.MatchString(input) { + c.SetDigest(input) + } +} + +// RemoveTagOrDigest removes tag or digest. +// Since tag and digest don't co-exist, so remove tag if tag not empty, otherwise remove digest. +func (c *Components) RemoveTagOrDigest() { + if c.tag != "" { + c.tag = "" + } else if c.digest != "" { + c.digest = "" + } +} + +// Parse returns a Components of the given image. +func Parse(image string) (*Components, error) { + ref, err := reference.Parse(image) + if err != nil { + return nil, err + } + + comp := &Components{} + if named, ok := ref.(reference.Named); ok { + comp.hostname, comp.repository = SplitHostname(named.Name()) + } + + if tagged, ok := ref.(reference.Tagged); ok { + comp.tag = tagged.Tag() + } else if digested, ok := ref.(reference.Digested); ok { + comp.digest = digested.Digest().String() + } + + return comp, nil +} + +// SplitHostname splits a repository name(ie: k8s.gcr.io/kube-apiserver) to hostname(k8s.gcr.io) and remotename(kube-apiserver) string. +func SplitHostname(name string) (hostname, remoteName string) { + i := strings.IndexRune(name, '/') + if i == -1 || (!strings.ContainsAny(name[:i], ".:") && name[:i] != "localhost") { + hostname, remoteName = "", name + } else { + hostname, remoteName = name[:i], name[i+1:] + } + return +} diff --git a/pkg/util/imageparser/parser_test.go b/pkg/util/imageparser/parser_test.go new file mode 100644 index 000000000..dbdf57568 --- /dev/null +++ b/pkg/util/imageparser/parser_test.go @@ -0,0 +1,191 @@ +package imageparser + +import ( + "fmt" + "testing" +) + +func TestParse(t *testing.T) { + tests := []struct { + name string + image string + expectHostname string + expectRepository string + expectTag string + expectDigest string + }{ + { + name: "simple", + image: "pause", + expectHostname: "", + expectRepository: "pause", + expectTag: "", + }, + { + name: "repository", + image: "subpath/imagename:v1.0.0", + expectHostname: "", + expectRepository: "subpath/imagename", + expectTag: "v1.0.0", + }, + { + name: "normal", + image: "fictional.registry.example/imagename:v1.0.0", + expectHostname: "fictional.registry.example", + expectRepository: "imagename", + expectTag: "v1.0.0", + }, + { + name: "hostname with port", + image: "fictional.registry.example:10443/subpath/imagename:v1.0.0", + expectHostname: "fictional.registry.example:10443", + expectRepository: "subpath/imagename", + expectTag: "v1.0.0", + }, + { + name: "digest", + image: "fictional.registry.example:10443/subpath/imagename@sha256:50d858e0985ecc7f60418aaf0cc5ab587f42c2570a884095a9e8ccacd0f6545c", + expectHostname: "fictional.registry.example:10443", + expectRepository: "subpath/imagename", + expectDigest: "sha256:50d858e0985ecc7f60418aaf0cc5ab587f42c2570a884095a9e8ccacd0f6545c", + }, + } + + for _, test := range tests { + tc := test + t.Run(tc.name, func(t *testing.T) { + comp, err := Parse(tc.image) + if err != nil { + t.Fatalf("unexpected error but got: %v", err) + } + if comp.String() != tc.image { + t.Fatalf("full name changed from %s to %s", tc.image, comp.String()) + } + if comp.Hostname() != tc.expectHostname { + t.Fatalf("expected registry: %s, but got: %s", tc.expectHostname, comp.Hostname()) + } + if comp.Repository() != tc.expectRepository { + t.Fatalf("expected name: %s, but got: %s", tc.expectRepository, comp.Repository()) + } + if comp.Tag() != tc.expectTag { + t.Fatalf("expected tag: %s, but got: %s", tc.expectTag, comp.Tag()) + } + if comp.Digest() != tc.expectDigest { + t.Fatalf("expected digest: %s, but got: %s", tc.expectDigest, comp.Digest()) + } + }) + } +} + +func TestSplitHostname(t *testing.T) { + tests := []struct { + name string + input string + expectedHostname string + expectedRepository string + }{ + { + name: "empty image got nothing", + input: "", + expectedHostname: "", + expectedRepository: "", + }, + { + name: "simple repository", + input: "imagename", + expectedHostname: "", + expectedRepository: "imagename", + }, + { + name: "repository with sub-path", + input: "subpath/imagename", + expectedHostname: "", + expectedRepository: "subpath/imagename", + }, + { + name: "canonical image", + input: "fictional.registry.example/subpath/imagename", + expectedHostname: "fictional.registry.example", + expectedRepository: "subpath/imagename", + }, + { + name: "hostname with port", + input: "fictional.registry.example:10443/subpath/imagename", + expectedHostname: "fictional.registry.example:10443", + expectedRepository: "subpath/imagename", + }, + { + name: "hostname with IP", + input: "10.10.10.10:10443/subpath/imagename", + expectedHostname: "10.10.10.10:10443", + expectedRepository: "subpath/imagename", + }, + } + + for _, test := range tests { + tc := test + t.Run(tc.name, func(t *testing.T) { + hostname, repository := SplitHostname(tc.input) + if hostname != tc.expectedHostname { + t.Fatalf("expected hostname: %s, but got: %s", tc.expectedHostname, hostname) + } + if repository != tc.expectedRepository { + t.Fatalf("expected repository: %s, but got: %s", tc.expectedRepository, repository) + } + }) + } +} + +func ExampleComponents_SetHostname() { + image := "imagename:v1.0.0" + comp, err := Parse(image) + if err != nil { + panic(err) + } + + comp.SetHostname("fictional.registry.example") // add hostname + fmt.Println(comp.String()) + comp.SetHostname("gcr.io") // update hostname + fmt.Println(comp.String()) + comp.RemoveHostname() // remove hostname + fmt.Println(comp.String()) + // Output: + // fictional.registry.example/imagename:v1.0.0 + // gcr.io/imagename:v1.0.0 + // imagename:v1.0.0 +} + +func ExampleComponents_SetRepository() { + image := "gcr.io/kube-apiserver:v1.19.0" + comp, err := Parse(image) + if err != nil { + panic(err) + } + + comp.SetRepository("kube-controller-manager") // update + fmt.Println(comp.String()) + // Output: + // gcr.io/kube-controller-manager:v1.19.0 +} + +func ExampleComponents_SetTagOrDigest() { + image := "gcr.io/kube-apiserver" + comp, err := Parse(image) + if err != nil { + panic(err) + } + + comp.SetTagOrDigest("v1.19.0") // set + fmt.Println(comp.String()) + comp.RemoveTagOrDigest() // remove tag + fmt.Println(comp.String()) + comp.SetTagOrDigest("sha256:50d858e0985ecc7f60418aaf0cc5ab587f42c2570a884095a9e8ccacd0f6545c") // update + fmt.Println(comp.String()) + comp.RemoveTagOrDigest() // remove digest + fmt.Println(comp.String()) + // Output: + // gcr.io/kube-apiserver:v1.19.0 + // gcr.io/kube-apiserver + // gcr.io/kube-apiserver@sha256:50d858e0985ecc7f60418aaf0cc5ab587f42c2570a884095a9e8ccacd0f6545c + // gcr.io/kube-apiserver +}