// Package tagcmp provides image tag comparison operations. package tagcmp import ( "fmt" "regexp" "strconv" "strings" ) type Tag struct { Major string `json:",omitempty"` // major semver part Minor string `json:",omitempty"` // minor semver part MissingMinor bool // whether or not the minor semver part was left out Patch string `json:",omitempty"` // patch semver part MissingPatch bool // whether or not he patch semver part was left out Suffix string // tag suffix (e.g. "-alpine") UsesV bool // whether or not the tag uses the "v" prefix } // ByTag sorts tags in asc/desc order where the last element is the latest tag. type ByTag []Tag func (t ByTag) Len() int { return len(t) } func (t ByTag) Swap(i, j int) { t[i], t[j] = t[j], t[i] } func (t ByTag) Less(i, j int) bool { return t[i].IsLessThan(t[j]) } // IsGreaterThan tests if a tag is greater than another. There are some // tag-isms to take into account here, shorter is bigger (i.e. 2.1 > 2.1.1 == // true, 2 > 2.1 == true). func (t Tag) IsGreaterThan(tag Tag) bool { // shorter is bigger, i.e. 2.1 > 2.1.1 if t.MissingPatch && !tag.MissingPatch || t.MissingMinor && !tag.MissingMinor { return true } if tag.MissingPatch && !t.MissingPatch || tag.MissingMinor && !t.MissingMinor { return false } // ignore errors since Parse already handled mj1, _ := strconv.Atoi(t.Major) mj2, _ := strconv.Atoi(tag.Major) if mj1 > mj2 { return true } if mj2 > mj1 { return false } mn1, _ := strconv.Atoi(t.Minor) mn2, _ := strconv.Atoi(tag.Minor) if mn1 > mn2 { return true } if mn2 > mn1 { return false } p1, _ := strconv.Atoi(t.Patch) p2, _ := strconv.Atoi(tag.Patch) if p1 > p2 { return true } if p2 > p1 { return false } return false } // IsLessThan tests if a tag is less than another. There are some tag-isms to // take into account here, shorter is bigger (i.e. 2.1 < 2.1.1 == false, 2 < // 2.1 == false). func (t Tag) IsLessThan(tag Tag) bool { if t.IsGreaterThan(tag) { return false } return true } // Equals tests Tag equality func (t Tag) Equals(tag Tag) bool { if t.MissingPatch && !tag.MissingPatch || t.MissingMinor && !tag.MissingMinor { return false } if tag.MissingPatch && !t.MissingPatch || tag.MissingMinor && !t.MissingMinor { return false } // ignore errors since Parse already handled mj1, _ := strconv.Atoi(t.Major) mj2, _ := strconv.Atoi(tag.Major) if mj1 != mj2 { return false } mn1, _ := strconv.Atoi(t.Minor) mn2, _ := strconv.Atoi(tag.Minor) if mn1 != mn2 { return false } p1, _ := strconv.Atoi(t.Patch) p2, _ := strconv.Atoi(tag.Patch) if p1 != p2 { return false } return true } // String formats a Tag correctly in string representation func (t Tag) String() string { var repr string if t.UsesV { repr += "v" } repr += t.Major if !t.MissingMinor { repr += fmt.Sprintf(".%s", t.Minor) } if !t.MissingPatch { repr += fmt.Sprintf(".%s", t.Patch) } if t.Suffix != "" { repr += fmt.Sprintf("-%s", t.Suffix) } return repr } // IsCompatible determines if two tags can be compared together func (t Tag) IsCompatible(tag Tag) bool { if t.UsesV && !tag.UsesV || tag.UsesV && !t.UsesV { return false } if t.Suffix != "" && tag.Suffix == "" { return false } if t.Suffix != "" && tag.Suffix != "" { if t.Suffix != tag.Suffix { return false } } if t.MissingMinor && !tag.MissingMinor || tag.MissingMinor && !t.MissingMinor { return false } if t.MissingPatch && !tag.MissingPatch || tag.MissingPatch && !t.MissingPatch { return false } return true } // CommitHashPattern matches commit-like hash tags var CommitHashPattern = "^[a-f0-9]{7,40}$" // DotPattern matches tags which contain multiple versions var DotPattern = "([0-9]+)\\.([0-9]+)" // EmptyPattern matches when tags are missing var EmptyPattern = "^$" // ParametrizedPattern matches when tags are parametrized var ParametrizedPattern = "\\${.+}" // StringPattern matches when tags are only made up of alphabetic characters var StringPattern = "^[a-zA-Z]+$" // patternMatches determines if a tag matches unsupported patterns func patternMatches(tag string) error { unsupported := []string{ CommitHashPattern, EmptyPattern, ParametrizedPattern, StringPattern, } for _, pattern := range unsupported { if match, _ := regexp.Match(pattern, []byte(tag)); match { return fmt.Errorf("'%s' is not supported (%s)", tag, pattern) } } return nil } // patternCounts determines if tags match unsupported patterns by counting occurences of matches func patternCounts(tag string) error { v := regexp.MustCompile(DotPattern) if m := v.FindAllStringIndex(tag, -1); len(m) > 1 { return fmt.Errorf("'%s' is not supported (%s)", tag, DotPattern) } return nil } // parseVersionPart converts a semver version part to an integer func parseVersionPart(part string) (int, error) { p, err := strconv.Atoi(part) if err != nil { return 0, err } return p, nil } // Parse converts an image tag into a structured data format. It aims to to // support the general case of tags which are "semver-like" and/or stable and // parseable by heuristics. Image tags follow no formal specification and // therefore this is a best-effort implementation. Examples of tags this // function can parse are: "5", "5.2", "v4", "v5.3.6", "4-alpine", // "v3.2.1-debian". func Parse(tag string) (Tag, error) { if err := patternMatches(tag); err != nil { return Tag{}, err } if err := patternCounts(tag); err != nil { return Tag{}, err } usesV := false if string(tag[0]) == "v" { tag = strings.TrimPrefix(tag, "v") usesV = true } var suffix string splits := strings.SplitN(tag, "-", 2) if len(splits) > 1 { tag = splits[0] suffix = splits[1] } var major, minor, patch string var missingMinor, missingPatch bool parts := strings.Split(tag, ".") switch { case len(parts) == 1: if _, err := parseVersionPart(parts[0]); err != nil { return Tag{}, fmt.Errorf("couldn't parse major part of '%s': '%s'", tag, parts[0]) } major = parts[0] missingMinor = true missingPatch = true case len(parts) == 2: if _, err := parseVersionPart(parts[0]); err != nil { return Tag{}, fmt.Errorf("couldn't parse major part of '%s': '%s'", tag, parts[0]) } major = parts[0] if _, err := parseVersionPart(parts[1]); err != nil { return Tag{}, fmt.Errorf("couldn't parse minor part of '%s': '%s'", tag, parts[1]) } minor = parts[1] missingPatch = true case len(parts) == 3: if _, err := parseVersionPart(parts[0]); err != nil { return Tag{}, fmt.Errorf("couldn't parse major part of '%s': '%s'", tag, parts[0]) } major = parts[0] if _, err := parseVersionPart(parts[1]); err != nil { return Tag{}, fmt.Errorf("couldn't parse minor part of '%s': '%s'", tag, parts[1]) } minor = parts[1] if _, err := parseVersionPart(parts[2]); err != nil { return Tag{}, fmt.Errorf("couldn't parse patch part of '%s': '%s'", tag, parts[2]) } patch = parts[2] default: return Tag{}, fmt.Errorf("couldn't parse semver of '%s", tag) } parsedTag := Tag{ Major: major, Minor: minor, MissingMinor: missingMinor, Patch: patch, MissingPatch: missingPatch, UsesV: usesV, Suffix: suffix, } return parsedTag, nil } // IsParsable determines if a tag is supported by this library func IsParsable(tag string) bool { if _, err := Parse(tag); err != nil { return false } return true }