// 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") [would be release candidate in semver] UsesV bool // whether or not the tag uses the "v" prefix Metadata string // metadata: what's after + and after the first "-" } type TagDelta struct { Major int // major semver difference Minor int // minor semver difference Patch int // patch semver difference } // ByTagAsc sorts tags in ascending order where the last element is the latest tag. type ByTagAsc []Tag func (t ByTagAsc) Len() int { return len(t) } func (t ByTagAsc) Swap(i, j int) { t[i], t[j] = t[j], t[i] } func (t ByTagAsc) Less(i, j int) bool { return t[i].IsLessThan(t[j]) } // ByTagDesc sorts tags in descending order where the first element is the latest tag. type ByTagDesc []Tag func (t ByTagDesc) Len() int { return len(t) } func (t ByTagDesc) Swap(i, j int) { t[j], t[i] = t[i], t[j] } func (t ByTagDesc) Less(i, j int) bool { return t[j].IsLessThan(t[i]) } // 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 { return !t.IsGreaterThan(tag) } // 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 } if t.Metadata != tag.Metadata { 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) return p1 == p2 } // 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) } if t.Metadata != "" { repr += fmt.Sprintf("+%s", t.Metadata) } return repr } func (t TagDelta) String() string { var repr string repr = fmt.Sprintf("%d.%d.%d", t.Major, t.Minor, t.Patch) 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 == "" || 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 } // IsUpgradeCompatible chekcs if upTag is compatible with a pinned version tag. // I.e. pinning to 22-fpm should return true if upTag is 22.2.0-fpm but not 22.2.0-alpine or 23.0.0-fpm func (pin Tag) IsUpgradeCompatible(upTag Tag) bool { if pin.Suffix != upTag.Suffix { return false } if pin.Major != upTag.Major { return false } if pin.MissingMinor { return true } if pin.Minor != upTag.Minor { return false } if pin.MissingPatch { return true } if pin.Patch != upTag.Patch { return false } return true } // UpgradeDelta returns a TagDelta object which is the difference between an old and new tag // It can contain negative numbers if comparing with an older tag. func (curTag Tag) UpgradeDelta(newTag Tag) (TagDelta, error) { if !curTag.IsCompatible(newTag) { return TagDelta{}, fmt.Errorf("%s and %s are not compatible with each other", curTag.String(), newTag.String()) } diff := TagDelta{ Major: 0, Minor: 0, Patch: 0, } // assuming tags are correctly formatted curMajor, _ := strconv.Atoi(curTag.Major) newMajor, _ := strconv.Atoi(newTag.Major) diff.Major = newMajor - curMajor if !curTag.MissingMinor { curMinor, _ := strconv.Atoi(curTag.Minor) newMinor, _ := strconv.Atoi(newTag.Minor) diff.Minor = newMinor - curMinor } if !curTag.MissingPatch { curPatch, _ := strconv.Atoi(curTag.Patch) newPatch, _ := strconv.Atoi(newTag.Patch) diff.Patch = newPatch - curPatch } return diff, nil } // UpgradeType takes exit from UpgradeElemene and returns a numeric representation of upgrade or downgrade // 1/-1: patch 2/-2: minor 4/-4: major 0: no change func (d TagDelta) UpgradeType() int { if d.Major > 0 { return 4 } if d.Major < 0 { return -4 } if d.Minor > 0 { return 2 } if d.Minor < 0 { return -2 } if d.Patch > 0 { return 1 } if d.Patch < 0 { return -1 } return 0 } // 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) tag = strings.Split(tag, "+")[0] 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 } // ParseDelta converts a tag difference in the format of X, X.Y or X.Y.Z where // X, Y, Z are positive or negative integers or 0 func ParseDelta(delta string) (TagDelta, error) { tagDelta := TagDelta{ Major: 0, Minor: 0, Patch: 0, } splits := strings.Split(delta, ".") if len(splits) > 3 { return TagDelta{}, fmt.Errorf("'%s' has too much dots", delta) } major, err := strconv.Atoi(splits[0]) if err != nil { return TagDelta{}, fmt.Errorf("Major part of '%s' is not an integer", delta) } tagDelta.Major = major if len(splits) > 1 { minor, err := strconv.Atoi(splits[1]) if err != nil { return TagDelta{}, fmt.Errorf("Minor part of '%s' is not an integer", delta) } tagDelta.Minor = minor } if len(splits) > 2 { patch, err := strconv.Atoi(splits[2]) if err != nil { return TagDelta{}, fmt.Errorf("Minor part of '%s' is not an integer", delta) } tagDelta.Patch = patch } return tagDelta, 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 metadata string splits := strings.Split(tag, "+") if len(splits) > 1 { tag = splits[0] metadata = splits[1] } 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, Metadata: metadata, } 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 }