2021-08-07 18:26:56 +00:00
|
|
|
// 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
|
2021-10-07 10:05:40 +00:00
|
|
|
Suffix string // tag suffix (e.g. "-alpine") [would be release candidate in semver]
|
2021-08-07 18:26:56 +00:00
|
|
|
UsesV bool // whether or not the tag uses the "v" prefix
|
2021-10-07 10:05:40 +00:00
|
|
|
Metadata string // metadata: what's after + and after the first "-"
|
2021-08-07 18:26:56 +00:00
|
|
|
}
|
|
|
|
|
2021-10-11 12:29:22 +00:00
|
|
|
type TagDelta struct {
|
|
|
|
Major int // major semver difference
|
|
|
|
Minor int // minor semver difference
|
|
|
|
Patch int // patch semver difference
|
|
|
|
}
|
|
|
|
|
2021-09-06 10:20:06 +00:00
|
|
|
// ByTagAsc sorts tags in ascending order where the last element is the latest tag.
|
|
|
|
type ByTagAsc []Tag
|
2021-08-07 18:26:56 +00:00
|
|
|
|
2021-09-06 10:20:06 +00:00
|
|
|
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 {
|
2021-08-07 18:26:56 +00:00
|
|
|
return t[i].IsLessThan(t[j])
|
|
|
|
}
|
|
|
|
|
2021-09-06 10:20:06 +00:00
|
|
|
// 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])
|
|
|
|
}
|
|
|
|
|
2021-08-07 18:26:56 +00:00
|
|
|
// 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 {
|
2021-08-13 11:47:41 +00:00
|
|
|
return !t.IsGreaterThan(tag)
|
2021-08-07 18:26:56 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// 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
|
|
|
|
}
|
|
|
|
|
2021-10-07 10:05:40 +00:00
|
|
|
if t.Metadata != tag.Metadata {
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
|
2021-08-07 18:26:56 +00:00
|
|
|
// 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)
|
2021-08-13 11:47:41 +00:00
|
|
|
return p1 == p2
|
2021-08-07 18:26:56 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
}
|
|
|
|
|
2021-10-07 10:05:40 +00:00
|
|
|
if t.Metadata != "" {
|
|
|
|
repr += fmt.Sprintf("+%s", t.Metadata)
|
|
|
|
}
|
|
|
|
|
2021-08-07 18:26:56 +00:00
|
|
|
return repr
|
|
|
|
}
|
|
|
|
|
2021-10-11 12:29:22 +00:00
|
|
|
func (t TagDelta) String() string {
|
|
|
|
var repr string
|
|
|
|
repr = fmt.Sprintf("%d.%d.%d", t.Major, t.Minor, t.Patch)
|
|
|
|
return repr
|
|
|
|
}
|
|
|
|
|
2021-08-07 18:26:56 +00:00
|
|
|
// 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
|
|
|
|
}
|
|
|
|
|
2021-08-09 13:05:03 +00:00
|
|
|
if t.Suffix != "" && tag.Suffix == "" || t.Suffix == "" && tag.Suffix != "" {
|
2021-08-07 18:26:56 +00:00
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2021-11-01 09:48:22 +00:00
|
|
|
// 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
|
|
|
|
}
|
|
|
|
|
2021-10-11 12:29:22 +00:00
|
|
|
// UpgradeDelta returns a TagDelta object which is the difference between an old and new tag
|
2021-10-01 17:11:21 +00:00
|
|
|
// It can contain negative numbers if comparing with an older tag.
|
2021-10-11 12:29:22 +00:00
|
|
|
func (curTag Tag) UpgradeDelta(newTag Tag) (TagDelta, error) {
|
2021-10-01 17:11:21 +00:00
|
|
|
if !curTag.IsCompatible(newTag) {
|
2021-10-11 12:29:22 +00:00
|
|
|
return TagDelta{}, fmt.Errorf("%s and %s are not compatible with each other", curTag.String(), newTag.String())
|
2021-10-01 17:11:21 +00:00
|
|
|
}
|
2021-10-11 12:29:22 +00:00
|
|
|
diff := TagDelta{
|
|
|
|
Major: 0,
|
|
|
|
Minor: 0,
|
|
|
|
Patch: 0,
|
2021-10-03 07:47:05 +00:00
|
|
|
}
|
2021-10-11 12:29:22 +00:00
|
|
|
// assuming tags are correctly formatted
|
|
|
|
curMajor, _ := strconv.Atoi(curTag.Major)
|
|
|
|
newMajor, _ := strconv.Atoi(newTag.Major)
|
|
|
|
diff.Major = newMajor - curMajor
|
2021-10-01 17:11:21 +00:00
|
|
|
if !curTag.MissingMinor {
|
2021-10-11 12:29:22 +00:00
|
|
|
curMinor, _ := strconv.Atoi(curTag.Minor)
|
|
|
|
newMinor, _ := strconv.Atoi(newTag.Minor)
|
|
|
|
diff.Minor = newMinor - curMinor
|
2021-10-01 17:11:21 +00:00
|
|
|
}
|
|
|
|
if !curTag.MissingPatch {
|
2021-10-11 12:29:22 +00:00
|
|
|
curPatch, _ := strconv.Atoi(curTag.Patch)
|
|
|
|
newPatch, _ := strconv.Atoi(newTag.Patch)
|
|
|
|
diff.Patch = newPatch - curPatch
|
2021-10-01 17:11:21 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
return diff, nil
|
|
|
|
}
|
|
|
|
|
2021-10-01 18:23:48 +00:00
|
|
|
// 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
|
2021-10-11 12:29:22 +00:00
|
|
|
func (d TagDelta) UpgradeType() int {
|
|
|
|
if d.Major > 0 {
|
2021-10-01 18:23:48 +00:00
|
|
|
return 4
|
|
|
|
}
|
2021-10-11 12:29:22 +00:00
|
|
|
if d.Major < 0 {
|
2021-10-01 18:23:48 +00:00
|
|
|
return -4
|
|
|
|
}
|
2021-10-11 12:29:22 +00:00
|
|
|
if d.Minor > 0 {
|
2021-10-01 18:23:48 +00:00
|
|
|
return 2
|
|
|
|
}
|
2021-10-11 12:29:22 +00:00
|
|
|
if d.Minor < 0 {
|
2021-10-01 18:23:48 +00:00
|
|
|
return -2
|
|
|
|
}
|
2021-10-11 12:29:22 +00:00
|
|
|
if d.Patch > 0 {
|
2021-10-01 18:23:48 +00:00
|
|
|
return 1
|
|
|
|
}
|
2021-10-11 12:29:22 +00:00
|
|
|
if d.Patch < 0 {
|
2021-10-01 18:23:48 +00:00
|
|
|
return -1
|
|
|
|
}
|
|
|
|
return 0
|
|
|
|
}
|
|
|
|
|
2021-08-07 18:26:56 +00:00
|
|
|
// 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)
|
2021-10-07 12:29:26 +00:00
|
|
|
tag = strings.Split(tag, "+")[0]
|
2021-08-07 18:26:56 +00:00
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2021-10-11 12:29:22 +00:00
|
|
|
// 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
|
|
|
|
}
|
|
|
|
|
2021-08-07 18:26:56 +00:00
|
|
|
// 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
|
|
|
|
}
|
|
|
|
|
2021-10-07 10:05:40 +00:00
|
|
|
var metadata string
|
|
|
|
splits := strings.Split(tag, "+")
|
|
|
|
if len(splits) > 1 {
|
|
|
|
tag = splits[0]
|
|
|
|
metadata = splits[1]
|
|
|
|
}
|
|
|
|
|
2021-08-07 18:26:56 +00:00
|
|
|
var suffix string
|
2021-10-07 10:05:40 +00:00
|
|
|
splits = strings.SplitN(tag, "-", 2)
|
2021-08-07 18:26:56 +00:00
|
|
|
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,
|
2021-10-07 10:05:40 +00:00
|
|
|
Metadata: metadata,
|
2021-08-07 18:26:56 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
return parsedTag, nil
|
|
|
|
}
|
|
|
|
|
2021-08-09 08:12:31 +00:00
|
|
|
// IsParsable determines if a tag is supported by this library
|
|
|
|
func IsParsable(tag string) bool {
|
2021-08-07 18:26:56 +00:00
|
|
|
if _, err := Parse(tag); err != nil {
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
return true
|
|
|
|
}
|