diff --git a/image/parse.go b/image/parse.go new file mode 100644 index 00000000..710d2e4c --- /dev/null +++ b/image/parse.go @@ -0,0 +1,110 @@ +package image + +import ( + "strings" +) + +// Reference is an opaque object that include identifier such as a name, tag, repository, registry, etc... +type Reference struct { + named Named + tag string +} + +// Name returns the image's name. (ie: debian[:8.2]) +func (r Reference) Name() string { + return r.named.RemoteName() + r.tag +} + +// ShortName returns the image's name (ie: debian) +func (r Reference) ShortName() string { + return r.named.RemoteName() +} + +// Tag returns the image's tag (or digest). +func (r Reference) Tag() string { + if len(r.tag) > 1 { + return r.tag[1:] + } + return "" +} + +// Registry returns the image's registry. (ie: host[:port]) +func (r Reference) Registry() string { + return r.named.Hostname() +} + +// Repository returns the image's repository. (ie: registry/name) +func (r Reference) Repository() string { + return r.named.FullName() +} + +// Remote returns the image's remote identifier. (ie: registry/name[:tag]) +func (r Reference) Remote() string { + return r.named.FullName() + r.tag +} + +func clean(url string) string { + + s := url + + if strings.HasPrefix(url, "http://") { + s = strings.Replace(url, "http://", "", 1) + } else if strings.HasPrefix(url, "https://") { + s = strings.Replace(url, "https://", "", 1) + } + + return s +} + +// Parse returns a Reference from analyzing the given remote identifier. +func Parse(remote string) (*Reference, error) { + + n, err := ParseNamed(clean(remote)) + + if err != nil { + return nil, err + } + + n = WithDefaultTag(n) + + var t string + switch x := n.(type) { + case Canonical: + t = "@" + x.Digest().String() + case NamedTagged: + t = ":" + x.Tag() + } + + return &Reference{named: n, tag: t}, nil +} + +// ParseRepo - parses remote +// pretty much the same as Parse but better for testing +func ParseRepo(remote string) (*Repository, error) { + + n, err := ParseNamed(clean(remote)) + + if err != nil { + return nil, err + } + + n = WithDefaultTag(n) + + var t string + switch x := n.(type) { + case Canonical: + t = "@" + x.Digest().String() + case NamedTagged: + t = ":" + x.Tag() + } + + ref := &Reference{named: n, tag: t} + + return &Repository{ + Name: ref.Name(), + Repository: ref.Repository(), + Registry: ref.Registry(), + ShortName: ref.ShortName(), + Tag: ref.Tag(), + }, nil +} diff --git a/image/parse_test.go b/image/parse_test.go new file mode 100644 index 00000000..532aaeb1 --- /dev/null +++ b/image/parse_test.go @@ -0,0 +1,79 @@ +package image + +import ( + "reflect" + "testing" +) + +func TestShortParseWithTag(t *testing.T) { + + reference, err := Parse("foo/bar:1.1") + if err != nil { + t.Errorf("error while parsing tag: %s", err) + } + + if reference.Tag() != "1.1" { + t.Errorf("unexpected tag: %s", reference.Tag()) + } + + if reference.Registry() != DefaultHostname { + t.Errorf("unexpected registry: %s", reference.Registry()) + } + + if reference.ShortName() != "foo/bar" { + t.Errorf("unexpected name: %s", reference.ShortName()) + } + + if reference.Name() != "foo/bar:1.1" { + t.Errorf("unexpected name: %s", reference.Name()) + } +} + +func TestParseRepo(t *testing.T) { + type args struct { + remote string + } + tests := []struct { + name string + args args + want *Repository + wantErr bool + }{ + { + name: "foo/bar:1.1", + args: args{remote: "foo/bar:1.1"}, + want: &Repository{ + Name: "foo/bar:1.1", + Repository: "docker.io/foo/bar", + Registry: DefaultHostname, + ShortName: "foo/bar", + Tag: "1.1", + }, + wantErr: false, + }, + { + name: "localhost.localdomain/foo/bar:1.1", + args: args{remote: "localhost.localdomain/foo/bar:1.1"}, + want: &Repository{ + Name: "foo/bar:1.1", + Repository: "localhost.localdomain/foo/bar", + Registry: "localhost.localdomain", + ShortName: "foo/bar", + Tag: "1.1", + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseRepo(tt.args.remote) + if (err != nil) != tt.wantErr { + t.Errorf("ParseRepo() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("ParseRepo() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/image/reference.go b/image/reference.go new file mode 100644 index 00000000..cb32a276 --- /dev/null +++ b/image/reference.go @@ -0,0 +1,207 @@ +package image + +import ( + "errors" + "fmt" + "strings" + + "github.com/docker/distribution/digest" + "github.com/docker/distribution/reference" +) + +const ( + // DefaultTag defines the default tag used when performing images related actions and no tag or digest is specified + DefaultTag = "latest" + // DefaultHostname is the default built-in hostname + DefaultHostname = "docker.io" + // LegacyDefaultHostname is automatically converted to DefaultHostname + LegacyDefaultHostname = "index.docker.io" + // DefaultRepoPrefix is the prefix used for default repositories in default host + DefaultRepoPrefix = "library/" +) + +// Repository is an object created from Named interface +type Repository struct { + Name string // Name returns the image's name. (ie: debian[:8.2]) + Repository string // Repository returns the image's repository. (ie: registry/name) + Registry string // Registry returns the image's registry. (ie: host[:port]) + ShortName string // ShortName returns the image's name (ie: debian) + Tag string // Tag returns the image's tag (or digest). +} + +// Named is an object with a full name +type Named interface { + // Name returns normalized repository name, like "ubuntu". + Name() string + // String returns full reference, like "ubuntu@sha256:abcdef..." + String() string + // FullName returns full repository name with hostname, like "docker.io/library/ubuntu" + FullName() string + // Hostname returns hostname for the reference, like "docker.io" + Hostname() string + // RemoteName returns the repository component of the full name, like "library/ubuntu" + RemoteName() string +} + +// NamedTagged is an object including a name and tag. +type NamedTagged interface { + Named + Tag() string +} + +// Canonical reference is an object with a fully unique +// name including a name with hostname and digest +type Canonical interface { + Named + Digest() digest.Digest +} + +// ParseNamed parses s and returns a syntactically valid reference implementing +// the Named interface. The reference must have a name, otherwise an error is +// returned. +// If an error was encountered it is returned, along with a nil Reference. +func ParseNamed(s string) (Named, error) { + named, err := reference.ParseNamed(s) + if err != nil { + return nil, fmt.Errorf("Error parsing reference: %q is not a valid repository/tag", s) + } + r, err := WithName(named.Name()) + if err != nil { + return nil, err + } + if canonical, isCanonical := named.(reference.Canonical); isCanonical { + return WithDigest(r, canonical.Digest()) + } + + if tagged, isTagged := named.(reference.NamedTagged); isTagged { + return WithTag(r, tagged.Tag()) + } + return r, nil +} + +// WithName returns a named object representing the given string. If the input +// is invalid ErrReferenceInvalidFormat will be returned. +func WithName(name string) (Named, error) { + name, err := normalize(name) + if err != nil { + return nil, err + } + if err := validateName(name); err != nil { + return nil, err + } + r, err := reference.WithName(name) + if err != nil { + return nil, err + } + return &namedRef{r}, nil +} + +// WithTag combines the name from "name" and the tag from "tag" to form a +// reference incorporating both the name and the tag. +func WithTag(name Named, tag string) (NamedTagged, error) { + r, err := reference.WithTag(name, tag) + if err != nil { + return nil, err + } + return &taggedRef{namedRef{r}}, nil +} + +// WithDigest combines the name from "name" and the digest from "digest" to form +// a reference incorporating both the name and the digest. +func WithDigest(name Named, digest digest.Digest) (Canonical, error) { + r, err := reference.WithDigest(name, digest) + if err != nil { + return nil, err + } + return &canonicalRef{namedRef{r}}, nil +} + +type namedRef struct { + reference.Named +} +type taggedRef struct { + namedRef +} +type canonicalRef struct { + namedRef +} + +func (r *namedRef) FullName() string { + hostname, remoteName := splitHostname(r.Name()) + return hostname + "/" + remoteName +} +func (r *namedRef) Hostname() string { + hostname, _ := splitHostname(r.Name()) + return hostname +} +func (r *namedRef) RemoteName() string { + _, remoteName := splitHostname(r.Name()) + return remoteName +} +func (r *taggedRef) Tag() string { + return r.namedRef.Named.(reference.NamedTagged).Tag() +} +func (r *canonicalRef) Digest() digest.Digest { + return r.namedRef.Named.(reference.Canonical).Digest() +} + +// WithDefaultTag adds a default tag to a reference if it only has a repo name. +func WithDefaultTag(ref Named) Named { + if IsNameOnly(ref) { + ref, _ = WithTag(ref, DefaultTag) + } + return ref +} + +// IsNameOnly returns true if reference only contains a repo name. +func IsNameOnly(ref Named) bool { + if _, ok := ref.(NamedTagged); ok { + return false + } + if _, ok := ref.(Canonical); ok { + return false + } + return true +} + +// splitHostname splits a repository name to hostname and remotename string. +// If no valid hostname is found, the default hostname is used. Repository name +// needs to be already validated before. +func splitHostname(name string) (hostname, remoteName string) { + i := strings.IndexRune(name, '/') + if i == -1 || (!strings.ContainsAny(name[:i], ".:") && name[:i] != "localhost") { + hostname, remoteName = DefaultHostname, name + } else { + hostname, remoteName = name[:i], name[i+1:] + } + if hostname == LegacyDefaultHostname { + hostname = DefaultHostname + } + if hostname == DefaultHostname && !strings.ContainsRune(remoteName, '/') { + remoteName = DefaultRepoPrefix + remoteName + } + return +} + +// normalize returns a repository name in its normalized form, meaning it +// will not contain default hostname nor library/ prefix for official images. +func normalize(name string) (string, error) { + host, remoteName := splitHostname(name) + if strings.ToLower(remoteName) != remoteName { + return "", errors.New("invalid reference format: repository name must be lowercase") + } + if host == DefaultHostname { + if strings.HasPrefix(remoteName, DefaultRepoPrefix) { + return strings.TrimPrefix(remoteName, DefaultRepoPrefix), nil + } + return remoteName, nil + } + return name, nil +} + +func validateName(name string) error { + if err := ValidateID(name); err == nil { + return fmt.Errorf("Invalid repository name (%s), cannot specify 64-byte hexadecimal strings", name) + } + return nil +} diff --git a/image/validation.go b/image/validation.go new file mode 100644 index 00000000..15f9aac6 --- /dev/null +++ b/image/validation.go @@ -0,0 +1,16 @@ +package image + +import ( + "fmt" + "regexp" +) + +var validHex = regexp.MustCompile(`^([a-f0-9]{64})$`) + +// ValidateID checks whether an ID string is a valid image ID. +func ValidateID(id string) error { + if ok := validHex.MatchString(id); !ok { + return fmt.Errorf("image ID '%s' is invalid ", id) + } + return nil +} \ No newline at end of file