Initial running API version

This commit is contained in:
Knut Ahlers 2021-11-22 03:39:25 +01:00
commit b364743a0b
Signed by: luzifer
GPG key ID: 0066F03ED215AD7D
17 changed files with 1489 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
config.yaml
.env
latestver.db

142
api.go Normal file
View file

@ -0,0 +1,142 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"github.com/Luzifer/go-latestver/internal/config"
"github.com/Luzifer/go-latestver/internal/database"
"github.com/Luzifer/go-latestver/internal/fetcher"
"github.com/gorilla/mux"
log "github.com/sirupsen/logrus"
)
func handleCatalogGet(w http.ResponseWriter, r *http.Request) {
var (
vars = mux.Vars(r)
name, tag = vars["name"], vars["tag"]
)
ce, err := configFile.CatalogEntryByTag(name, tag)
if errors.Is(err, config.ErrCatalogEntryNotFound) {
http.Error(w, "Not found", http.StatusNotFound)
return
}
cm, err := storage.Catalog.GetMeta(&ce)
if err != nil {
log.WithError(err).Error("Unable to fetch catalog meta")
http.Error(w, "Unable to fetch catalog meta", http.StatusInternalServerError)
return
}
ce.Links = append(
ce.Links,
fetcher.Get(ce.Fetcher).Links(&ce.FetcherConfig)...,
)
ce.CatalogMeta = *cm
w.Header().Set("Content-Type", "application/json")
if err = json.NewEncoder(w).Encode(ce); err != nil {
log.WithError(err).Error("Unable to encode catalog entry")
http.Error(w, "Unable to encode catalog meta", http.StatusInternalServerError)
return
}
}
func handleCatalogGetVersion(w http.ResponseWriter, r *http.Request) {
var (
vars = mux.Vars(r)
name, tag = vars["name"], vars["tag"]
)
ce, err := configFile.CatalogEntryByTag(name, tag)
if errors.Is(err, config.ErrCatalogEntryNotFound) {
http.Error(w, "Not found", http.StatusNotFound)
return
}
cm, err := storage.Catalog.GetMeta(&ce)
if err != nil {
log.WithError(err).Error("Unable to fetch catalog meta")
http.Error(w, "Unable to fetch catalog meta", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/plain")
fmt.Fprint(w, cm.CurrentVersion)
}
func handleCatalogList(w http.ResponseWriter, r *http.Request) {
out := make([]database.CatalogEntry, len(configFile.Catalog))
for i := range configFile.Catalog {
ce := configFile.Catalog[i]
cm, err := storage.Catalog.GetMeta(&ce)
if err != nil {
log.WithError(err).Error("Unable to fetch catalog meta")
http.Error(w, "Unable to fetch catalog meta", http.StatusInternalServerError)
return
}
ce.Links = append(
ce.Links,
fetcher.Get(ce.Fetcher).Links(&ce.FetcherConfig)...,
)
ce.CatalogMeta = *cm
out[i] = ce
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(out); err != nil {
log.WithError(err).Error("Unable to encode catalog entry list")
http.Error(w, "Unable to encode catalog meta", http.StatusInternalServerError)
return
}
}
func handleLog(w http.ResponseWriter, r *http.Request) {
var (
vars = mux.Vars(r)
name, tag = vars["name"], vars["tag"]
num, page = 25, 0
err error
logs []database.LogEntry
)
if v, err := strconv.Atoi(r.FormValue("num")); err == nil && v > 0 && v < 100 {
num = v
}
if v, err := strconv.Atoi(r.FormValue("page")); err == nil && v >= 0 {
page = v
}
if name == "" && tag == "" {
logs, err = storage.Logs.List(num, page)
} else {
ce, err := configFile.CatalogEntryByTag(name, tag)
if errors.Is(err, config.ErrCatalogEntryNotFound) {
http.Error(w, "Not found", http.StatusNotFound)
return
}
logs, err = storage.Logs.ListForCatalogEntry(&ce, num, page)
}
w.Header().Set("Content-Type", "application/json")
if err = json.NewEncoder(w).Encode(logs); err != nil {
log.WithError(err).Error("Unable to encode logs")
http.Error(w, "Unable to encode logs", http.StatusInternalServerError)
return
}
}
func handleLogFeed(w http.ResponseWriter, r *http.Request) {} // FIXME

51
go.mod Normal file
View file

@ -0,0 +1,51 @@
module github.com/Luzifer/go-latestver
go 1.17
require (
github.com/Luzifer/go_helpers/v2 v2.13.0
github.com/Luzifer/rconfig/v2 v2.4.0
github.com/gorilla/mux v1.8.0
github.com/pkg/errors v0.9.1
github.com/robfig/cron/v3 v3.0.1
github.com/sirupsen/logrus v1.8.1
gopkg.in/src-d/go-git.v4 v4.13.1
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b
gorm.io/driver/mysql v1.2.0
gorm.io/driver/sqlite v1.2.4
gorm.io/gorm v1.22.3
)
require (
github.com/Microsoft/go-winio v0.4.16 // indirect
github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7 // indirect
github.com/acomagu/bufpipe v1.0.3 // indirect
github.com/antchfx/htmlquery v1.2.4 // indirect
github.com/antchfx/xpath v1.2.0 // indirect
github.com/blang/semver/v4 v4.0.0 // indirect
github.com/emirpasic/gods v1.12.0 // indirect
github.com/go-git/gcfg v1.5.0 // indirect
github.com/go-git/go-billy/v5 v5.3.1 // indirect
github.com/go-git/go-git/v5 v5.4.2 // indirect
github.com/go-sql-driver/mysql v1.6.0 // indirect
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e // indirect
github.com/imdario/mergo v0.3.12 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.2 // indirect
github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351 // indirect
github.com/mattn/go-sqlite3 v1.14.9 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/sergi/go-diff v1.1.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/src-d/gcfg v1.4.0 // indirect
github.com/xanzy/ssh-agent v0.3.0 // indirect
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b // indirect
golang.org/x/net v0.0.0-20210326060303-6b1517762897 // indirect
golang.org/x/sys v0.0.0-20210502180810-71e4cd670f79 // indirect
golang.org/x/text v0.3.5 // indirect
gopkg.in/src-d/go-billy.v4 v4.3.2 // indirect
gopkg.in/validator.v2 v2.0.0-20210331031555-b37d688a7fb0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)

269
go.sum Normal file
View file

@ -0,0 +1,269 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/Luzifer/go_helpers v1.4.0 h1:Pmm058SbYewfnpP1CHda/zERoAqYoZFiBHF4l8k03Ko=
github.com/Luzifer/go_helpers v1.4.0/go.mod h1:5yUSe0FS7lIx1Uzmt0R3tdPFrSSaPfiCqaIA6u0Zn4Y=
github.com/Luzifer/go_helpers/v2 v2.13.0 h1:R/U4qEyf8J3kwBbOzFrE/ongBc4purK/gq2uwDgsDaI=
github.com/Luzifer/go_helpers/v2 v2.13.0/go.mod h1:HVMy8b4LwntHF8AtCf0LrAlYGRAumstLus8oK67e0hU=
github.com/Luzifer/rconfig/v2 v2.4.0 h1:MAdymTlExAZ8mx5VG8xOFAtFQSpWBipKYQHPOmYTn9o=
github.com/Luzifer/rconfig/v2 v2.4.0/go.mod h1:hWF3ZVSusbYlg5bEvCwalEyUSY+0JPJWUiIu7rBmav8=
github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA=
github.com/Microsoft/go-winio v0.4.16 h1:FtSW/jqD+l4ba5iPBj9CODVtgfYAD8w2wS923g/cFDk=
github.com/Microsoft/go-winio v0.4.16/go.mod h1:XB6nPKklQyQ7GC9LdcBEcBl8PF76WugXOPRXwdLnMv0=
github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7 h1:YoJbenK9C67SkzkDfmQuVln04ygHj3vjZfd9FL+GmQQ=
github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7/go.mod h1:z4/9nQmJSSwwds7ejkxaJwO37dru3geImFUdJlaLzQo=
github.com/acomagu/bufpipe v1.0.3 h1:fxAGrHZTgQ9w5QqVItgzwj235/uYZYgbXitB+dLupOk=
github.com/acomagu/bufpipe v1.0.3/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4=
github.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7/go.mod h1:6zEj6s6u/ghQa61ZWa/C2Aw3RkjiTBOix7dkqa1VLIs=
github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c=
github.com/antchfx/htmlquery v1.2.4 h1:qLteofCMe/KGovBI6SQgmou2QNyedFUW+pE+BpeZ494=
github.com/antchfx/htmlquery v1.2.4/go.mod h1:2xO6iu3EVWs7R2JYqBbp8YzG50gj/ofqs5/0VZoDZLc=
github.com/antchfx/xpath v1.2.0 h1:mbwv7co+x0RwgeGAOHdrKy89GvHaGvxxBtPK0uF9Zr8=
github.com/antchfx/xpath v1.2.0/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM=
github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/emirpasic/gods v1.12.0 h1:QAUIPSaCu4G+POclxeqb3F+WPpdKqFGlw36+yOzGlrg=
github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0=
github.com/go-git/gcfg v1.5.0 h1:Q5ViNfGF8zFgyJWPqYwA7qGFoMTEiBmdlkcfRmpIMa4=
github.com/go-git/gcfg v1.5.0/go.mod h1:5m20vg6GwYabIxaOonVkTdrILxQMpEShl1xiMF4ua+E=
github.com/go-git/go-billy/v5 v5.2.0/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0=
github.com/go-git/go-billy/v5 v5.3.1 h1:CPiOUAzKtMRvolEKw+bG1PLRpT7D3LIs3/3ey4Aiu34=
github.com/go-git/go-billy/v5 v5.3.1/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0=
github.com/go-git/go-git-fixtures/v4 v4.2.1/go.mod h1:K8zd3kDUAykwTdDCr+I0per6Y6vMiRR/nnVTBtavnB0=
github.com/go-git/go-git/v5 v5.4.2 h1:BXyZu9t0VkbiHtqrsvdq39UDhGJTl1h55VW6CSC4aY4=
github.com/go-git/go-git/v5 v5.4.2/go.mod h1:gQ1kArt6d+n+BGd+/B/I74HwRTLhth2+zti4ihgckDc=
github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e h1:1r7pUrabqp18hOBcwBwiTsbnFeTZHV9eER/QT5JVZxY=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU=
github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf/go.mod h1:hyb9oH7vZsitZCiBt0ZvifOrB+qc8PS5IiilCIb87rg=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.2 h1:eVKgfIdy9b6zbWBMgFpfDPoAMifwSZagU9HmEU6zgiI=
github.com/jinzhu/now v1.1.2/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd h1:Coekwdh0v2wtGp9Gmz1Ze3eVRAWJMLokvN3QjdzCHLY=
github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351 h1:DowS9hvgyYSX4TO5NpyC606/Z4SxnNYbT+WX27or6Ck=
github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leekchan/gtf v0.0.0-20190214083521-5fba33c5b00b/go.mod h1:thNruaSwydMhkQ8dXzapABF9Sc1Tz08ZBcDdgott9RA=
github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA=
github.com/mattn/go-sqlite3 v1.14.9 h1:10HX2Td0ocZpYEjhilsuo6WWtUqttj2Kb0KtD86/KYA=
github.com/mattn/go-sqlite3 v1.14.9/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/nxadm/tail v1.4.6/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/ginkgo v1.15.0/go.mod h1:hF8qUzuuC8DJGygJH3726JnCZX4MYbRB8yFfISqnKUg=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/onsi/gomega v1.10.5/go.mod h1:gza4q3jKQJijlu05nKWRCW/GavJumGt8aNRxWg7mt48=
github.com/pelletier/go-buffruneio v0.2.0/go.mod h1:JkE26KsDizTr40EUHkXVtNPvgGtbSNq5BcowyYOWdKo=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE=
github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/src-d/gcfg v1.4.0 h1:xXbNR5AlLSA315x2UO+fTSSAXCDf+Ar38/6oyGbDKQ4=
github.com/src-d/gcfg v1.4.0/go.mod h1:p/UMsR43ujA89BJY9duynAwIpvqEujIH/jFlfL7jWoI=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/xanzy/ssh-agent v0.2.1 h1:TCbipTQL2JiiCprBWx9frJ2eJlCYT00NmctrHxVAr70=
github.com/xanzy/ssh-agent v0.2.1/go.mod h1:mLlQY/MoOhWBj+gOGMQkOeiEvkx+8pJSI+0Bx9h2kr4=
github.com/xanzy/ssh-agent v0.3.0 h1:wUMzuKtKilRgBAD1sUb8gOwwRr2FGoBVumcjoOACClI=
github.com/xanzy/ssh-agent v0.3.0/go.mod h1:3s9xbODqPuuhK9JV1R321M/FlMZSBvE5aY6eAcqrDh0=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b h1:7mWr3k41Qtv8XlltBkDkl8LoP3mpSgBW8BUoxtEdbXg=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200421231249-e086a090c8fd/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210119194325-5f4716e94777 h1:003p0dJM77cxMSyCPFphvZf/Y5/NXf5fzg6ufd1/Oew=
golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210326060303-6b1517762897 h1:KrsHThm5nFk34YtATK1LsThyGhGbGe1olrte/HInHvs=
golang.org/x/net v0.0.0-20210326060303-6b1517762897/go.mod h1:uSPa2vr4CLtc/ILN5odXGNXS6mhrKVzTaCXzk9m6W3k=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190221075227-b4e8571b14e0/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 h1:YyJpGZS1sBuBCzLAR1VEpK193GlqGZbnPFnPV/5Rsb4=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c h1:VwygUrnw9jn88c4u8GD3rZQbqrP/tgas88tPUbBxQrk=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210324051608-47abb6519492/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210502180810-71e4cd670f79 h1:RX8C8PRZc2hTIod4ds8ij+/4RQX3AqhYj3uOHmyaz4E=
golang.org/x/sys v0.0.0-20210502180810-71e4cd670f79/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.5 h1:i6eZZ+zk0SOf0xgBpEpPD18qWcJda6q1sxt3S0kzyUQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190729092621-ff9f1409240a/go.mod h1:jcCCGcm9btYwXyDqrUWc6MKQKKGJCWEQ3AfLSRIbEuI=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/src-d/go-billy.v4 v4.3.2 h1:0SQA1pRztfTFx2miS8sA97XvooFeNOmvUenF4o0EcVg=
gopkg.in/src-d/go-billy.v4 v4.3.2/go.mod h1:nDjArDMp+XMs1aFAESLRjfGSgfvoYN0hDfzEk0GjC98=
gopkg.in/src-d/go-git-fixtures.v3 v3.5.0/go.mod h1:dLBcvytrw/TYZsNTWCnkNF2DSIlzWYqTe3rJR56Ac7g=
gopkg.in/src-d/go-git.v4 v4.13.1 h1:SRtFyV8Kxc0UP7aCHcijOMQGPxHSmMOPrzulQWolkYE=
gopkg.in/src-d/go-git.v4 v4.13.1/go.mod h1:nx5NYcxdKxq5fpltdHnPa2Exj4Sx0EclMWZQbYDu2z8=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/validator.v2 v2.0.0-20210331031555-b37d688a7fb0 h1:EFLtLCwd8tGN+r/ePz3cvRtdsfYNhDEdt/vp6qsT+0A=
gopkg.in/validator.v2 v2.0.0-20210331031555-b37d688a7fb0/go.mod h1:o4V0GXN9/CAmCsvJ0oXYZvrZOe7syiDZSN1GWGZTGzc=
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.2.0 h1:l8+9VwjjyzEkw0PNPBOr2JHhLOGVk7XEnl5hk42bcvs=
gorm.io/driver/mysql v1.2.0/go.mod h1:4RQmTg4okPghdt+kbe6e1bTXIQp7Ny1NnBn/3Z6ghjk=
gorm.io/driver/sqlite v1.2.4 h1:jx16ESo1WzNjgBJNSbhEDoMKJnlhkU8BuBR2C0GC7D8=
gorm.io/driver/sqlite v1.2.4/go.mod h1:n8/CTEIEmo7lKrehQI4pd+rz6O514tMkBeCAR5UTXLs=
gorm.io/gorm v1.22.2/go.mod h1:F+OptMscr0P2F2qU97WT1WimdH9GaQPoDW7AYd5i2Y0=
gorm.io/gorm v1.22.3 h1:/JS6z+GStEQvJNW3t1FTwJwG/gZ+A7crFdRqtvG5ehA=
gorm.io/gorm v1.22.3/go.mod h1:F+OptMscr0P2F2qU97WT1WimdH9GaQPoDW7AYd5i2Y0=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

65
internal/config/config.go Normal file
View file

@ -0,0 +1,65 @@
package config
import (
"os"
"time"
"github.com/Luzifer/go-latestver/internal/database"
"github.com/Luzifer/go-latestver/internal/fetcher"
"github.com/pkg/errors"
"gopkg.in/yaml.v3"
)
var ErrCatalogEntryNotFound = errors.New("catalog entry not found")
type (
File struct {
Catalog []database.CatalogEntry `yaml:"catalog"`
CheckInterval time.Duration `yaml:"check_interval"`
}
)
func New() *File {
return &File{
CheckInterval: time.Hour,
}
}
func (f File) CatalogEntryByTag(name, tag string) (database.CatalogEntry, error) {
for i := range f.Catalog {
ce := f.Catalog[i]
if ce.Name == name && ce.Tag == tag {
return ce, nil
}
}
return database.CatalogEntry{}, ErrCatalogEntryNotFound
}
func (f *File) Load(filepath string) error {
fh, err := os.Open(filepath)
if err != nil {
return errors.Wrap(err, "opening config file")
}
defer fh.Close()
dec := yaml.NewDecoder(fh)
dec.KnownFields(true)
return errors.Wrap(dec.Decode(f), "decoding config")
}
func (f File) ValidateCatalog() error {
for i, ce := range f.Catalog {
f := fetcher.Get(ce.Fetcher)
if f == nil {
return errors.Errorf("catalog entry %d has unknown fetcher", i)
}
if err := f.Validate(&ce.FetcherConfig); err != nil {
return errors.Wrapf(err, "catalog entry %d has invalid fetcher config", i)
}
}
return nil
}

87
internal/database/db.go Normal file
View file

@ -0,0 +1,87 @@
package database
import (
"fmt"
"io"
"time"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
"gorm.io/driver/mysql"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
type (
Client struct {
Catalog CatalogMetaStore
Logs LogStore
db *gorm.DB
}
logwrap struct {
l *io.PipeWriter
}
)
func NewClient(dbtype, dsn string) (*Client, error) {
c := &Client{}
c.Catalog = CatalogMetaStore{c}
c.Logs = LogStore{c}
dbLogger := logger.New(
&logwrap{log.StandardLogger().WriterLevel(log.TraceLevel)},
logger.Config{
SlowThreshold: time.Second,
LogLevel: logger.Silent,
IgnoreRecordNotFoundError: true,
Colorful: false,
},
)
switch dbtype {
case "mysql":
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: dbLogger,
})
if err != nil {
return nil, errors.Wrap(err, "opening mysql database")
}
c.db = db
case "sqlite", "sqlite3":
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{
Logger: dbLogger,
})
if err != nil {
return nil, errors.Wrap(err, "opening sqlite3 database")
}
c.db = db
default:
return nil, errors.Errorf("invalid db type: %s", dbtype)
}
if err := c.initDB(); err != nil {
return nil, errors.Wrap(err, "initializing database")
}
return c, nil
}
func (c Client) initDB() error {
for name, fn := range map[string]func() error{
"catalogMeta": c.Catalog.ensureTable,
"log": c.Logs.ensureTable,
} {
if err := fn(); err != nil {
return errors.Wrapf(err, "ensuring tables: %s", name)
}
}
return nil
}
func (l logwrap) Printf(f string, v ...interface{}) { fmt.Fprintf(l.l, f, v...) }

View file

@ -0,0 +1,24 @@
package database
import "testing"
func Test_CreateInvalidDatabase(t *testing.T) {
_, err := NewClient("invalid", "")
if err == nil {
t.Fatal("client creation for type 'invalid' did not cause error")
}
}
func Test_CreateInaccessibleSqlite(t *testing.T) {
_, err := NewClient("sqlite", "/this/path/should/really/not/exist.db")
if err == nil {
t.Fatal("client creation with unavailable sqlite path did not cause error")
}
}
func Test_CreateInaccessibleMYSQL(t *testing.T) {
_, err := NewClient("mysql", "user:pass@tcp(127.0.0.1:70000)/dbname?charset=utf8mb4&parseTime=True&loc=Local")
if err == nil {
t.Fatal("client creation with unavailable mysql did not cause error")
}
}

120
internal/database/store.go Normal file
View file

@ -0,0 +1,120 @@
package database
import (
_ "embed"
"strings"
"time"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/pkg/errors"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type (
CatalogEntry struct {
Name string `json:"name" yaml:"name"`
Tag string `json:"tag" yaml:"tag"`
Fetcher string `json:"-" yaml:"fetcher"`
FetcherConfig fieldcollection.FieldCollection `json:"-" yaml:"fetcher_config"`
CheckInterval time.Duration `json:"-" yaml:"check_interval"`
Links []CatalogLink `json:"links" yaml:"links"`
CatalogMeta `yaml:"-"`
}
CatalogLink struct {
IconClass string `json:"icon_class" yaml:"icon_class"`
Name string `json:"name" yaml:"name"`
URL string `json:"url" yaml:"url"`
}
CatalogMeta struct {
CatalogName string `gorm:"primaryKey" json:"-"`
CatalogTag string `gorm:"primaryKey" json:"-"`
CurrentVersion string `json:"current_version,omitempty"`
LastChecked *time.Time `json:"last_checked,omitempty"`
VersionTime *time.Time `json:"version_time,omitempty"`
}
LogEntry struct {
CatalogName string `gorm:"index:catalog_key" json:"catalog_name"`
CatalogTag string `gorm:"index:catalog_key" json:"catalog_tag"`
Timestamp time.Time `gorm:"index:,sort:desc" json:"timestamp"`
VersionTo string `json:"version_to"`
VersionFrom string `json:"version_from"`
}
CatalogMetaStore struct {
c *Client
}
LogStore struct {
c *Client
}
)
func (c CatalogEntry) Key() string { return strings.Join([]string{c.Name, c.Tag}, ":") }
func (c CatalogMetaStore) GetMeta(ce *CatalogEntry) (*CatalogMeta, error) {
out := &CatalogMeta{
CatalogName: ce.Name,
CatalogTag: ce.Tag,
}
err := c.c.db.
Where(out).
First(out).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
// If there is no meta yet we just return empty meta
err = nil
}
return out, errors.Wrap(err, "querying metadata")
}
func (c CatalogMetaStore) PutMeta(cm *CatalogMeta) error {
return errors.Wrap(
c.c.db.Clauses(clause.OnConflict{UpdateAll: true}).Create(cm).Error,
"writing catalog meta",
)
}
func (c CatalogMetaStore) ensureTable() error {
return errors.Wrap(c.c.db.AutoMigrate(&CatalogMeta{}), "applying migration")
}
func (l LogStore) Add(le *LogEntry) error {
return errors.Wrap(
l.c.db.Create(le).Error,
"writing log entry",
)
}
func (l LogStore) List(num, page int) ([]LogEntry, error) {
return l.listWithFilter(l.c.db, num, page)
}
func (l LogStore) ListForCatalogEntry(ce *CatalogEntry, num, page int) ([]LogEntry, error) {
return l.listWithFilter(l.c.db.Where(&LogEntry{CatalogName: ce.Name, CatalogTag: ce.Tag}), num, page)
}
func (l LogStore) listWithFilter(filter *gorm.DB, num, page int) ([]LogEntry, error) {
var out []LogEntry
return out, errors.Wrap(
filter.
Order("timestamp desc").
Limit(num).Offset(num*page).
Find(&out).
Error,
"fetching log entries",
)
}
func (l LogStore) ensureTable() error {
return errors.Wrap(l.c.db.AutoMigrate(&LogEntry{}), "applying migration")
}

View file

@ -0,0 +1,130 @@
package database
import (
"testing"
"time"
)
const sqlliteMemoryDSN = "file::memory:?cache=shared"
func Test_CatalogMetaStorage(t *testing.T) {
ttp := func(v time.Time) *time.Time { return &v }
dbc, err := NewClient("sqlite3", sqlliteMemoryDSN)
if err != nil {
t.Fatalf("unable to create database client: %s", err)
}
var (
ce = CatalogEntry{Name: "testapp", Tag: "latest"}
cm = CatalogMeta{CatalogName: ce.Name, CatalogTag: ce.Tag, CurrentVersion: "1.0.0", LastChecked: ttp(time.Now())}
)
// Empty fetch
fetchedCM, err := dbc.Catalog.GetMeta(&ce)
if err != nil {
t.Fatalf("unable to retrieve catalog meta: %s", err)
}
for name, check := range map[string]bool{
"name match": fetchedCM.CatalogName == ce.Name,
"tag match": fetchedCM.CatalogTag == ce.Tag,
"version empty": fetchedCM.CurrentVersion == "",
"date nil": fetchedCM.LastChecked == nil,
} {
if !check {
t.Errorf("check failed: %s", name)
}
}
// Initial set
if err = dbc.Catalog.PutMeta(&cm); err != nil {
t.Fatalf("unable to store catalog meta: %s", err)
}
fetchedCM, err = dbc.Catalog.GetMeta(&ce)
if err != nil {
t.Fatalf("unable to retrieve catalog meta: %s", err)
}
for name, check := range map[string]bool{
"name match": fetchedCM.CatalogName == ce.Name,
"tag match": fetchedCM.CatalogTag == ce.Tag,
"version match": fetchedCM.CurrentVersion == cm.CurrentVersion,
"date match": fetchedCM.LastChecked.Equal(*cm.LastChecked),
} {
if !check {
t.Errorf("check failed: %s", name)
}
}
// Update
cm.LastChecked = ttp(time.Now().Add(time.Hour)) // Compensate test running quite fast
cm.CurrentVersion = "1.1.0"
if err = dbc.Catalog.PutMeta(&cm); err != nil {
t.Fatalf("unable to update catalog meta: %s", err)
}
fetchedCM, err = dbc.Catalog.GetMeta(&ce)
if err != nil {
t.Fatalf("unable to retrieve catalog meta: %s", err)
}
for name, check := range map[string]bool{
"name match": fetchedCM.CatalogName == ce.Name,
"tag match": fetchedCM.CatalogTag == ce.Tag,
"version match": fetchedCM.CurrentVersion == cm.CurrentVersion,
"date match": fetchedCM.LastChecked.Equal(*cm.LastChecked),
} {
if !check {
t.Errorf("check failed: %s", name)
}
}
}
func Test_LogStorage(t *testing.T) {
dbc, err := NewClient("sqlite3", sqlliteMemoryDSN)
if err != nil {
t.Fatalf("unable to create database client: %s", err)
}
var (
ce = CatalogEntry{Name: "testapp", Tag: "latest"}
rt = time.Now()
)
for _, le := range []LogEntry{
{CatalogName: ce.Name, CatalogTag: ce.Tag, Timestamp: rt.Add(-3 * time.Hour), VersionFrom: "1.0.0", VersionTo: "1.1.0"},
{CatalogName: ce.Name, CatalogTag: ce.Tag, Timestamp: rt.Add(-1 * time.Hour), VersionFrom: "1.2.0", VersionTo: "1.3.0"},
{CatalogName: ce.Name, CatalogTag: ce.Tag, Timestamp: rt.Add(-2 * time.Hour), VersionFrom: "1.1.0", VersionTo: "1.2.0"},
{CatalogName: "anotherapp", CatalogTag: ce.Tag, Timestamp: rt.Add(-2 * time.Hour), VersionFrom: "5.2.0", VersionTo: "5.2.1"},
{CatalogName: "anotherapp", CatalogTag: ce.Tag, Timestamp: rt.Add(-1 * time.Hour), VersionFrom: "5.2.1", VersionTo: "6.0.0"},
} {
if err = dbc.Logs.Add(&le); err != nil {
t.Fatalf("unable to add log entry: %s", err)
}
}
logs, err := dbc.Logs.ListForCatalogEntry(&ce, 100, 0)
if err != nil {
t.Fatalf("unable to fetch log entries for entry: %s", err)
}
if c := len(logs); c != 3 {
t.Errorf("got unexpected number of logs for entry: %d != 3", c)
}
if !logs[2].Timestamp.Before(logs[1].Timestamp) || !logs[1].Timestamp.Before(logs[0].Timestamp) {
t.Error("log entries are not sorted descending")
}
logs, err = dbc.Logs.List(100, 0)
if err != nil {
t.Fatalf("unable to fetch log entries: %s", err)
}
if c := len(logs); c != 5 {
t.Errorf("got unexpected number of logs: %d != 5", c)
}
}

102
internal/fetcher/git.go Normal file
View file

@ -0,0 +1,102 @@
package fetcher
import (
"context"
"time"
"github.com/Luzifer/go-latestver/internal/database"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/storage/memory"
"github.com/pkg/errors"
)
type (
GitTagFetcher struct{}
)
func init() { registerFetcher("git_tag", func() Fetcher { return &GitTagFetcher{} }) }
func (g GitTagFetcher) FetchVersion(ctx context.Context, attrs *fieldcollection.FieldCollection) (string, time.Time, error) {
repo, err := git.Init(memory.NewStorage(), nil)
if err != nil {
return "", time.Time{}, errors.Wrap(err, "opening in-mem repo")
}
_, err = repo.CreateRemote(&config.RemoteConfig{
Name: "origin",
URLs: []string{attrs.MustString("remote", nil)},
})
if err != nil {
return "", time.Time{}, errors.Wrap(err, "adding remote")
}
if err = repo.Fetch(&git.FetchOptions{
Depth: 1,
RefSpecs: []config.RefSpec{"+refs/tags/*:refs/remotes/origin/tags/*"},
RemoteName: "origin",
}); err != nil {
return "", time.Time{}, errors.Wrap(err, "fetching remote")
}
tags, err := repo.Tags()
if err != nil {
return "", time.Time{}, errors.Wrap(err, "listing tags")
}
var (
latestTag *plumbing.Reference
latestTagTime time.Time
)
if err = tags.ForEach(func(ref *plumbing.Reference) error {
tt, err := g.tagRefToTime(repo, ref)
if err != nil {
return errors.Wrap(err, "fetching time for tag")
}
if latestTag == nil || tt.After(latestTagTime) {
latestTag = ref
latestTagTime = tt
}
return nil
}); err != nil {
return "", time.Time{}, errors.Wrap(err, "iterating tags")
}
if latestTag == nil {
return "", time.Time{}, ErrNoVersionFound
}
return latestTag.Name().Short(), latestTagTime, nil
}
func (g GitTagFetcher) Links(attrs *fieldcollection.FieldCollection) []database.CatalogLink {
return nil
}
func (g GitTagFetcher) Validate(attrs *fieldcollection.FieldCollection) error {
if v, err := attrs.String("remote"); err != nil || v == "" {
return errors.New("remote is expected to be non-empty string")
}
return nil
}
func (g GitTagFetcher) tagRefToTime(repo *git.Repository, tag *plumbing.Reference) (time.Time, error) {
tagObj, err := repo.TagObject(tag.Hash())
if err == nil {
// Annotated tag: Take the time of the tag
return tagObj.Tagger.When, nil
}
commitObj, err := repo.CommitObject(tag.Hash())
if err == nil {
// Lightweight tag: Take the time of the commit
return commitObj.Committer.When, nil
}
return time.Time{}, errors.New("reference points neither to tag nor to commit")
}

View file

@ -0,0 +1,45 @@
package fetcher
import (
"context"
"testing"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
)
func Test_GitTagFetcher(t *testing.T) {
attrs := fieldcollection.FieldCollectionFromData(map[string]interface{}{
"remote": "https://github.com/WordPress/WordPress.git", // This is a huge repo and serves as benchmark too
})
f := Get("git_tag")
if err := f.Validate(attrs); err != nil {
t.Fatalf("validating attributes: %s", err)
}
ver, _, err := f.FetchVersion(context.Background(), attrs)
if err != nil {
t.Fatalf("fetching version: %s", err)
}
// Uses tag format: 1.0.0
if len(ver) < 5 {
t.Errorf("version has unexpected format: %s != X.X.X", ver)
}
t.Logf("found tag: %s", ver)
}
func Test_GitTagFetcherInvalid(t *testing.T) {
attrs := fieldcollection.FieldCollectionFromData(map[string]interface{}{
"remote": "https://example.com/example.git",
})
f := Get("git_tag")
_, _, err := f.FetchVersion(context.Background(), attrs)
if err == nil {
t.Fatalf("fetching version dit not cause error")
}
}

View file

@ -0,0 +1,98 @@
package fetcher
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"time"
"github.com/Luzifer/go-latestver/internal/database"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/pkg/errors"
)
const githubHTTPTimeout = 2 * time.Second
type (
GithubReleaseFetcher struct{}
githubRelease struct {
TagName string `json:"tag_name"`
PublishedAt time.Time `json:"published_at"`
Prerelease bool `json:"prerelease"`
}
)
func init() { registerFetcher("github_release", func() Fetcher { return &GithubReleaseFetcher{} }) }
func (g GithubReleaseFetcher) FetchVersion(ctx context.Context, attrs *fieldcollection.FieldCollection) (string, time.Time, error) {
ctx, cancel := context.WithTimeout(ctx, githubHTTPTimeout)
defer cancel()
req, err := http.NewRequestWithContext(
ctx,
http.MethodGet,
fmt.Sprintf("https://api.github.com/repos/%s/releases", attrs.MustString("repository", nil)),
nil,
)
if err != nil {
return "", time.Time{}, errors.Wrap(err, "creating http request")
}
req.Header.Set("User-Agent", "Luzifer/go-latestver GithubReleaseFetcher")
if os.Getenv("GITHUB_CLIENT_ID") != "" && os.Getenv("GITHUB_CLIENT_SECRET") != "" {
req.SetBasicAuth(os.Getenv("GITHUB_CLIENT_ID"), os.Getenv("GITHUB_CLIENT_SECRET"))
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", time.Time{}, errors.Wrap(err, "executing request")
}
if resp.StatusCode != http.StatusOK {
return "", time.Time{}, errors.Errorf("unexpected HTTP status %d", resp.StatusCode)
}
defer resp.Body.Close()
var payload []githubRelease
if err = json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return "", time.Time{}, errors.Wrap(err, "decoding response")
}
var release *githubRelease
for i := range payload {
if payload[i].Prerelease {
continue
}
if release == nil || release.PublishedAt.Before(payload[i].PublishedAt) {
release = &payload[i]
}
}
if release == nil {
return "", time.Time{}, ErrNoVersionFound
}
return release.TagName, release.PublishedAt, nil
}
func (g GithubReleaseFetcher) Links(attrs *fieldcollection.FieldCollection) []database.CatalogLink {
return []database.CatalogLink{
{
IconClass: "fab fa-github",
Name: "Repository",
URL: fmt.Sprintf("https://github.com/%s", attrs.MustString("repository", nil)),
},
}
}
func (g GithubReleaseFetcher) Validate(attrs *fieldcollection.FieldCollection) error {
if v, err := attrs.String("repository"); err != nil || v == "" {
return errors.New("repository is expected to be non-empty string")
}
return nil
}

View file

@ -0,0 +1,45 @@
package fetcher
import (
"context"
"testing"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
)
func Test_GithubReleaseFetcher(t *testing.T) {
attrs := fieldcollection.FieldCollectionFromData(map[string]interface{}{
"repository": "Luzifer/korvike",
})
f := Get("github_release")
if err := f.Validate(attrs); err != nil {
t.Fatalf("validating attributes: %s", err)
}
ver, _, err := f.FetchVersion(context.Background(), attrs)
if err != nil {
t.Fatalf("fetching version: %s", err)
}
// Uses tag format: v1.0.0
if len(ver) < 6 || ver[0] != 'v' {
t.Errorf("version has unexpected format: %s != vX.X.X", ver)
}
t.Logf("found release: %s", ver)
}
func Test_GithubReleaseFetcherInvalid(t *testing.T) {
attrs := fieldcollection.FieldCollectionFromData(map[string]interface{}{
"repository": "Luzifer/thiswillneverexist",
})
f := Get("github_release")
_, _, err := f.FetchVersion(context.Background(), attrs)
if err == nil {
t.Fatalf("fetching version dit not cause error")
}
}

81
internal/fetcher/html.go Normal file
View file

@ -0,0 +1,81 @@
package fetcher
import (
"context"
"regexp"
"time"
"github.com/Luzifer/go-latestver/internal/database"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/antchfx/htmlquery"
"github.com/antchfx/xpath"
"github.com/pkg/errors"
"golang.org/x/net/html"
)
var htmlFetcherDefaultRegex = `(v?(?:[0-9]+\.?){2,})`
type (
HTMLFetcher struct{}
)
func init() { registerFetcher("html", func() Fetcher { return &HTMLFetcher{} }) }
func (h HTMLFetcher) FetchVersion(ctx context.Context, attrs *fieldcollection.FieldCollection) (string, time.Time, error) {
doc, err := htmlquery.LoadURL(attrs.MustString("url", nil))
if err != nil {
return "", time.Time{}, errors.Wrap(err, "loading URL")
}
node, err := htmlquery.Query(doc, attrs.MustString("xpath", nil))
if err != nil {
return "", time.Time{}, errors.Wrap(err, "querying xpath")
}
if node.Type == html.ElementNode && node.FirstChild.Type == html.TextNode {
node = node.FirstChild
}
if node.Type != html.TextNode {
return "", time.Time{}, errors.Errorf("xpath expression lead to unexpected node type: %d", node.Type)
}
match := regexp.MustCompile(attrs.MustString("regex", &htmlFetcherDefaultRegex)).FindStringSubmatch(node.Data)
if len(match) < 2 {
return "", time.Time{}, errors.New("regular expression did not yield version")
}
return match[1], time.Now(), nil
}
func (h HTMLFetcher) Links(attrs *fieldcollection.FieldCollection) []database.CatalogLink {
return []database.CatalogLink{
{
IconClass: "fas fa-globe",
Name: "Website",
URL: attrs.MustString("url", nil),
},
}
}
func (h HTMLFetcher) Validate(attrs *fieldcollection.FieldCollection) error {
if v, err := attrs.String("url"); err != nil || v == "" {
return errors.New("url is expected to be non-empty string")
}
if v, err := attrs.String("xpath"); err != nil || v == "" {
return errors.New("xpath is expected to be non-empty string")
}
if _, err := xpath.Compile(attrs.MustString("xpath", nil)); err != nil {
return errors.Wrap(err, "compiling xpath expression")
}
if attrs.CanString("regex") {
if _, err := regexp.Compile(attrs.MustString("regex", nil)); err != nil {
return errors.Wrap(err, "invalid regex given")
}
}
return nil
}

View file

@ -0,0 +1,46 @@
package fetcher
import (
"context"
"sync"
"time"
"github.com/Luzifer/go-latestver/internal/database"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/pkg/errors"
)
type (
Fetcher interface {
FetchVersion(ctx context.Context, attrs *fieldcollection.FieldCollection) (string, time.Time, error)
Links(attrs *fieldcollection.FieldCollection) []database.CatalogLink
Validate(attrs *fieldcollection.FieldCollection) error
}
FetcherCreate func() Fetcher
)
var (
ErrNoVersionFound = errors.New("no version found")
availableFetchers = map[string]FetcherCreate{}
availableFetchersLock sync.RWMutex
)
func registerFetcher(name string, fn FetcherCreate) {
availableFetchersLock.Lock()
defer availableFetchersLock.Unlock()
availableFetchers[name] = fn
}
func Get(name string) Fetcher {
availableFetchersLock.RLock()
defer availableFetchersLock.RUnlock()
fn, ok := availableFetchers[name]
if !ok {
return nil
}
return fn()
}

92
main.go Normal file
View file

@ -0,0 +1,92 @@
package main
import (
"fmt"
"net/http"
"os"
"time"
"github.com/gorilla/mux"
"github.com/robfig/cron/v3"
log "github.com/sirupsen/logrus"
"github.com/Luzifer/go-latestver/internal/config"
"github.com/Luzifer/go-latestver/internal/database"
httpHelper "github.com/Luzifer/go_helpers/v2/http"
"github.com/Luzifer/rconfig/v2"
)
var (
cfg = struct {
Config string `flag:"config,c" default:"config.yaml" description:"Configuration file with catalog entries"`
Listen string `flag:"listen" default:":3000" description:"Port/IP to listen on"`
LogLevel string `flag:"log-level" default:"info" description:"Log level (debug, info, warn, error, fatal)"`
MaxJitter time.Duration `flag:"max-jitter" default:"30m" description:"Maximum jitter to add to the check interval for load balancing"`
Storage string `flag:"storage" default:"sqlite" description:"Storage adapter to use (mysql, sqlite)"`
StorageDSN string `flag:"storage-dsn" default:"file::memory:?cache=shared" description:"DSN to connect to the database"`
VersionAndExit bool `flag:"version" default:"false" description:"Prints current version and exits"`
}{}
configFile = config.New()
storage *database.Client
version = "dev"
)
func initApp() {
rconfig.AutoEnv(true)
if err := rconfig.ParseAndValidate(&cfg); err != nil {
log.Fatalf("Unable to parse commandline options: %s", err)
}
if cfg.VersionAndExit {
fmt.Printf("go-latestver %s\n", version)
os.Exit(0)
}
if l, err := log.ParseLevel(cfg.LogLevel); err != nil {
log.WithError(err).Fatal("Unable to parse log level")
} else {
log.SetLevel(l)
}
}
func main() {
initApp()
var err error
if err = configFile.Load(cfg.Config); err != nil {
log.WithError(err).Fatal("Unable to load configuration")
}
if err = configFile.ValidateCatalog(); err != nil {
log.WithError(err).Fatal("Configuration is not valid")
}
storage, err = database.NewClient(cfg.Storage, cfg.StorageDSN)
if err != nil {
log.WithError(err).Fatal("Unable to connect to database")
}
scheduler := cron.New()
scheduler.AddFunc("@every 1m", schedulerRun)
scheduler.Start()
router := mux.NewRouter()
router.HandleFunc("/v1/catalog", handleCatalogList).Methods(http.MethodGet)
router.HandleFunc("/v1/catalog/{name}/{tag}", handleCatalogGet).Methods(http.MethodGet)
router.HandleFunc("/v1/catalog/{name}/{tag}/log", handleLog).Methods(http.MethodGet)
router.HandleFunc("/v1/catalog/{name}/{tag}/log.rss", handleLogFeed).Methods(http.MethodGet)
router.HandleFunc("/v1/catalog/{name}/{tag}/version", handleCatalogGetVersion).Methods(http.MethodGet)
router.HandleFunc("/v1/log", handleLog).Methods(http.MethodGet)
router.HandleFunc("/v1/log.rss", handleLogFeed).Methods(http.MethodGet)
var handler http.Handler = router
handler = httpHelper.GzipHandler(handler)
handler = httpHelper.NewHTTPLogHandler(handler)
if err := http.ListenAndServe(cfg.Listen, handler); err != nil {
log.WithError(err).Fatal("HTTP server exited unclean")
}
}

89
scheduler.go Normal file
View file

@ -0,0 +1,89 @@
package main
import (
"context"
"strings"
"time"
"github.com/Luzifer/go-latestver/internal/database"
"github.com/Luzifer/go-latestver/internal/fetcher"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
)
var schedulerRunActive bool
func schedulerRun() {
if schedulerRunActive {
return
}
schedulerRunActive = true
defer func() { schedulerRunActive = false }()
for _, ce := range configFile.Catalog {
if err := checkForUpdates(&ce); err != nil {
log.WithField("entry", ce.Key()).WithError(err).Error("Unable to update entry")
}
}
}
func checkForUpdates(ce *database.CatalogEntry) error {
logger := log.WithField("entry", ce.Key())
cm, err := storage.Catalog.GetMeta(ce)
if err != nil {
return errors.Wrap(err, "getting catalog meta")
}
checkTime := time.Now()
if cm.LastChecked != nil {
checkTime = *cm.LastChecked
switch {
case ce.CheckInterval > 0:
checkTime = checkTime.Add(ce.CheckInterval)
case configFile.CheckInterval > 0:
checkTime = checkTime.Add(configFile.CheckInterval)
default:
checkTime = checkTime.Add(time.Hour)
}
}
if checkTime.After(time.Now()) {
// Not yet ready to check
return nil
}
logger.Debug("Checking for updates")
ver, vertime, err := fetcher.Get(ce.Fetcher).FetchVersion(context.Background(), &ce.FetcherConfig)
if err != nil {
return errors.Wrap(err, "fetching version")
}
ver = strings.TrimPrefix(ver, "v")
if cm.CurrentVersion != ver {
if err = storage.Logs.Add(&database.LogEntry{
CatalogName: ce.Name,
CatalogTag: ce.Tag,
Timestamp: vertime,
VersionTo: ver,
VersionFrom: cm.CurrentVersion,
}); err != nil {
return errors.Wrap(err, "adding log entry")
}
logger.WithFields(log.Fields{
"from": cm.CurrentVersion,
"to": ver,
}).Info("Entry had version update")
cm.VersionTime = func(v time.Time) *time.Time { return &v }(vertime)
}
cm.CurrentVersion = ver
cm.LastChecked = func(v time.Time) *time.Time { return &v }(time.Now())
return errors.Wrap(storage.Catalog.PutMeta(cm), "updating meta entry")
}