From 333a8ffe9b7255cc124102c56dc33eda799ec371 Mon Sep 17 00:00:00 2001 From: Knut Ahlers Date: Sun, 7 Aug 2022 16:15:36 +0200 Subject: [PATCH] [#6] Implement version constraints / downgrade protection Signed-off-by: Knut Ahlers --- docs/config.md | 7 +++ docs/config.md.tpl | 7 +++ internal/database/store.go | 3 ++ internal/version/constraint.go | 68 ++++++++++++++++++++++++++++ internal/version/numeric_dot.go | 68 ++++++++++++++++++++++++++++ internal/version/numeric_dot_test.go | 27 +++++++++++ internal/version/semver.go | 51 +++++++++++++++++++++ internal/version/semver_test.go | 27 +++++++++++ scheduler.go | 35 +++++++++++--- 9 files changed, 286 insertions(+), 7 deletions(-) create mode 100644 internal/version/constraint.go create mode 100644 internal/version/numeric_dot.go create mode 100644 internal/version/numeric_dot_test.go create mode 100644 internal/version/semver.go create mode 100644 internal/version/semver_test.go diff --git a/docs/config.md b/docs/config.md index 71dddea..a9b3625 100644 --- a/docs/config.md +++ b/docs/config.md @@ -18,6 +18,11 @@ catalog: name: 'Website' url: 'https://alpinelinux.org' + version_constraint: + allow_downgrade: false + allow_prerelease: false + type: semver + check_interval: 1h ... @@ -29,6 +34,8 @@ Additionally you will configure a `fetcher` with its corresponding `fetcher_conf You can provide your own `links` for each catalog entry which will be added to or override the links returned from the fetcher. If you provide the same `name` as the fetcher uses the link of the fetcher will be overridden. The `icon_class` should consist of `fas` or `fab` and an icon (for example `fa-globe`). You can use all **solid** (`fas`) or **breand** (`fab`) icons within [Font Awesome v5 Free](https://fontawesome.com/v5.15/icons?d=gallery&s=brands,solid&m=free). +For the `version_constraint`s you must specify a `type` to parse the version returned by the fetcher (currently `semver` and `numeric_dot` (`104.0.5112.79`) are supported) and then can allow downgrades and pre-releases in the version. If no constraint is present, versions are neither parsed nor checked for downgrade / pre-release. If only the `type` is specified, downgrades and pre-releases are forbidden. + ## Available Fetchers ## Fetcher: `atlassian` diff --git a/docs/config.md.tpl b/docs/config.md.tpl index 6a955b7..3fd8370 100644 --- a/docs/config.md.tpl +++ b/docs/config.md.tpl @@ -18,6 +18,11 @@ catalog: name: 'Website' url: 'https://alpinelinux.org' + version_constraint: + allow_downgrade: false + allow_prerelease: false + type: semver + check_interval: 1h ... @@ -29,6 +34,8 @@ Additionally you will configure a `fetcher` with its corresponding `fetcher_conf You can provide your own `links` for each catalog entry which will be added to or override the links returned from the fetcher. If you provide the same `name` as the fetcher uses the link of the fetcher will be overridden. The `icon_class` should consist of `fas` or `fab` and an icon (for example `fa-globe`). You can use all **solid** (`fas`) or **breand** (`fab`) icons within [Font Awesome v5 Free](https://fontawesome.com/v5.15/icons?d=gallery&s=brands,solid&m=free). +For the `version_constraint`s you must specify a `type` to parse the version returned by the fetcher (currently `semver` and `numeric_dot` (`104.0.5112.79`) are supported) and then can allow downgrades and pre-releases in the version. If no constraint is present, versions are neither parsed nor checked for downgrade / pre-release. If only the `type` is specified, downgrades and pre-releases are forbidden. + ## Available Fetchers {% for module in modules -%} diff --git a/internal/database/store.go b/internal/database/store.go index eff8429..40bb2f5 100644 --- a/internal/database/store.go +++ b/internal/database/store.go @@ -8,6 +8,7 @@ import ( "gorm.io/gorm" "gorm.io/gorm/clause" + "github.com/Luzifer/go-latestver/internal/version" "github.com/Luzifer/go_helpers/v2/fieldcollection" ) @@ -19,6 +20,8 @@ type ( Fetcher string `json:"-" yaml:"fetcher"` FetcherConfig *fieldcollection.FieldCollection `json:"-" yaml:"fetcher_config"` + VersionConstraint *version.Constraint `json:"-" yaml:"version_constraint"` + Links []CatalogLink `json:"links" yaml:"links"` } diff --git a/internal/version/constraint.go b/internal/version/constraint.go new file mode 100644 index 0000000..87496cf --- /dev/null +++ b/internal/version/constraint.go @@ -0,0 +1,68 @@ +package version + +import "github.com/pkg/errors" + +type ( + Constraint struct { + AllowDowngrade bool `yaml:"allow_downgrade"` + AllowPrerelease bool `yaml:"allow_prerelease"` + + Type string `yaml:"type"` + } + + comparer interface { + Compare(oldVersion, newVersion string) (compareResult, error) + IsPrerelease(newVersion string) (bool, error) + } + + compareResult uint +) + +const ( + compareResultInvalid compareResult = iota + compareResultEqual + compareResultDowngrade + compareResultUpgrade +) + +func (c Constraint) ShouldApply(oldVersion, newVersion string) (bool, error) { + comp := c.getComparer() + if comp == nil { + return false, errors.New("invalid version type specified") + } + + // Compare versions and check for UpgradeOnly flag + compResult, err := comp.Compare(oldVersion, newVersion) + if err != nil { + return false, errors.Wrap(err, "comparing versions") + } + + if !c.AllowDowngrade && compResult != compareResultUpgrade { + return false, nil + } + + // check for forbidden pre-releases + isPreR, err := comp.IsPrerelease(newVersion) + if err != nil { + return false, errors.Wrap(err, "checking pre-release") + } + + if !c.AllowPrerelease && isPreR { + return false, nil + } + + return true, nil +} + +func (c Constraint) getComparer() comparer { + switch c.Type { + case "numeric_dot": + return numericDotSeparatedComparer{} + + case "semver": + return semVerComparer{} + + default: + return nil + } +} diff --git a/internal/version/numeric_dot.go b/internal/version/numeric_dot.go new file mode 100644 index 0000000..daf254b --- /dev/null +++ b/internal/version/numeric_dot.go @@ -0,0 +1,68 @@ +package version + +import ( + "math" + "strconv" + "strings" + + "github.com/pkg/errors" +) + +type ( + numericDotSeparatedComparer struct{} +) + +var _ comparer = numericDotSeparatedComparer{} + +func (n numericDotSeparatedComparer) Compare(oldVersion, newVersion string) (compareResult, error) { + oldV, err := n.parse(oldVersion) + if err != nil { + return compareResultInvalid, errors.Wrap(err, "parsing old version") + } + + newV, err := n.parse(newVersion) + if err != nil { + return compareResultInvalid, errors.Wrap(err, "parsing old version") + } + + getSeg := func(v []int, i int) int { + if i >= len(v) { + return 0 + } + return v[i] + } + + for i := 0; i < int(math.Max(float64(len(oldV)), float64(len(newV)))); i++ { + switch { + case getSeg(oldV, i) < getSeg(newV, i): + return compareResultUpgrade, nil + + case getSeg(oldV, i) > getSeg(newV, i): + return compareResultDowngrade, nil + + default: + continue + } + } + + return compareResultEqual, nil +} + +func (numericDotSeparatedComparer) IsPrerelease(string) (bool, error) { + // Numeric Dot has no marker for Pre-Releases + return false, nil +} + +func (numericDotSeparatedComparer) parse(ver string) ([]int, error) { + var out []int + + for _, seg := range strings.Split(ver, ".") { + segI, err := strconv.Atoi(seg) + if err != nil { + return nil, errors.Wrap(err, "parsing segment") + } + out = append(out, segI) + } + + return out, nil +} diff --git a/internal/version/numeric_dot_test.go b/internal/version/numeric_dot_test.go new file mode 100644 index 0000000..439a88b --- /dev/null +++ b/internal/version/numeric_dot_test.go @@ -0,0 +1,27 @@ +package version + +import "testing" + +func TestNumericDotSeparatedCompareFunc(t *testing.T) { + comp := numericDotSeparatedComparer{} + + for _, tc := range []struct { + v1, v2 string + res compareResult + }{ + {"2.1.0", "2.1.1", compareResultUpgrade}, + {"2.1.1", "2.1.0", compareResultDowngrade}, + {"2.1.1", "2.1.1", compareResultEqual}, + {"103.0.5060.134", "104.0.5112.79", compareResultUpgrade}, + {"2022.7.7", "2022.8.0", compareResultUpgrade}, + } { + res, err := comp.Compare(tc.v1, tc.v2) + if err != nil { + t.Errorf("Comparing %q to %q: %s", tc.v1, tc.v2, err) + } + + if res != tc.res { + t.Errorf("Comparing %q to %q: expected %v, got %v", tc.v1, tc.v2, tc.res, res) + } + } +} diff --git a/internal/version/semver.go b/internal/version/semver.go new file mode 100644 index 0000000..991e412 --- /dev/null +++ b/internal/version/semver.go @@ -0,0 +1,51 @@ +package version + +import ( + "github.com/blang/semver/v4" + "github.com/pkg/errors" +) + +type ( + semVerComparer struct{} +) + +var _ comparer = semVerComparer{} + +func (semVerComparer) Compare(oldVersion, newVersion string) (compareResult, error) { + oldS, err := semver.Make(oldVersion) + if err != nil { + return compareResultInvalid, errors.Wrap(err, "parsing old version") + } + + newS, err := semver.Make(newVersion) + if err != nil { + return compareResultInvalid, errors.Wrap(err, "parsing new version") + } + + switch oldS.Compare(newS) { + case -1: + // oldS < newS + return compareResultUpgrade, nil + + case 0: + // oldS == newS + return compareResultEqual, nil + + case 1: + // oldS > newS + return compareResultDowngrade, nil + + default: + // WTF, that does not exist according to lib docs + return compareResultInvalid, errors.New("invalid compare result") + } +} + +func (semVerComparer) IsPrerelease(newVersion string) (bool, error) { + newS, err := semver.Make(newVersion) + if err != nil { + return false, errors.Wrap(err, "parsing version") + } + + return newS.Pre != nil, nil +} diff --git a/internal/version/semver_test.go b/internal/version/semver_test.go new file mode 100644 index 0000000..05b634a --- /dev/null +++ b/internal/version/semver_test.go @@ -0,0 +1,27 @@ +package version + +import "testing" + +func TestSemVerCompareFunc(t *testing.T) { + comp := semVerComparer{} + + for _, tc := range []struct { + v1, v2 string + res compareResult + }{ + {"1.0.0", "2.0.0", compareResultUpgrade}, + {"2.0.0", "2.1.0", compareResultUpgrade}, + {"2.1.0", "2.1.1", compareResultUpgrade}, + {"2.1.1", "2.1.0", compareResultDowngrade}, + {"2.1.1", "2.1.1", compareResultEqual}, + } { + res, err := comp.Compare(tc.v1, tc.v2) + if err != nil { + t.Errorf("Comparing %q to %q: %s", tc.v1, tc.v2, err) + } + + if res != tc.res { + t.Errorf("Comparing %q to %q: expected %v, got %v", tc.v1, tc.v2, tc.res, res) + } + } +} diff --git a/scheduler.go b/scheduler.go index 8fc072b..a100bd7 100644 --- a/scheduler.go +++ b/scheduler.go @@ -59,18 +59,35 @@ func checkForUpdates(ce *database.CatalogEntry) error { ver = strings.TrimPrefix(ver, "v") vertime = vertime.Truncate(time.Second).UTC() + logger = logger.WithFields(log.Fields{ + "from": cm.CurrentVersion, + "to": ver, + }) + + var ( + compareErr error + shouldUpdate = true + ) + if ce.VersionConstraint != nil { + shouldUpdate, compareErr = ce.VersionConstraint.ShouldApply(cm.CurrentVersion, ver) + } + switch { case err != nil: - log.WithField("entry", ce.Key()).WithError(err).Error("Fetcher caused error, error is stored in entry") + logger.WithError(err).Error("Fetcher caused error, error is stored in entry") cm.Error = err.Error() - case cm.CurrentVersion != ver: + case compareErr != nil: + logger.WithError(err).Error("Version compare caused error, error is stored in entry") + cm.Error = compareErr.Error() - logger.WithFields(log.Fields{ - "from": cm.CurrentVersion, - "to": ver, - }).Info("Entry had version update") + case cm.CurrentVersion != ver && !shouldUpdate: + logger.Info("Version-updated prevented by constraints") + cm.Error = "" + + case cm.CurrentVersion != ver && shouldUpdate: + logger.Info("Entry had version update") if err = storage.Logs.Add(&database.LogEntry{ CatalogName: ce.Name, @@ -84,7 +101,11 @@ func checkForUpdates(ce *database.CatalogEntry) error { cm.VersionTime = ptrTime(vertime) cm.CurrentVersion = ver - fallthrough + cm.Error = "" + + case cm.CurrentVersion == ver: + logger.Debug("Version did not change") + cm.Error = "" default: cm.Error = ""