[v8.3.x] Plugins: Refactor Grafana and Plugin version update checkers (#44662)

* 44529

* fix test
This commit is contained in:
Will Browne
2022-01-31 16:49:29 +01:00
committed by GitHub
parent 5b37d14b7f
commit ac8f1b5b6b
15 changed files with 554 additions and 264 deletions
+115
View File
@@ -0,0 +1,115 @@
package updatechecker
import (
"context"
"encoding/json"
"io/ioutil"
"net/http"
"strings"
"sync"
"time"
"github.com/hashicorp/go-version"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/setting"
)
type GrafanaService struct {
hasUpdate bool
latestVersion string
enabled bool
grafanaVersion string
httpClient http.Client
mutex sync.RWMutex
log log.Logger
}
func ProvideGrafanaService(cfg *setting.Cfg) *GrafanaService {
return &GrafanaService{
enabled: cfg.CheckForUpdates,
grafanaVersion: cfg.BuildVersion,
httpClient: http.Client{Timeout: 10 * time.Second},
log: log.New("grafana.update.checker"),
}
}
func (s *GrafanaService) IsDisabled() bool {
return !s.enabled
}
func (s *GrafanaService) Run(ctx context.Context) error {
s.checkForUpdates()
ticker := time.NewTicker(time.Minute * 10)
run := true
for run {
select {
case <-ticker.C:
s.checkForUpdates()
case <-ctx.Done():
run = false
}
}
return ctx.Err()
}
func (s *GrafanaService) checkForUpdates() {
resp, err := s.httpClient.Get("https://raw.githubusercontent.com/grafana/grafana/main/latest.json")
if err != nil {
s.log.Debug("Failed to get latest.json repo from github.com", "error", err)
return
}
defer func() {
if err := resp.Body.Close(); err != nil {
s.log.Warn("Failed to close response body", "err", err)
}
}()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
s.log.Debug("Update check failed, reading response from github.com", "error", err)
return
}
type latestJSON struct {
Stable string `json:"stable"`
Testing string `json:"testing"`
}
var latest latestJSON
err = json.Unmarshal(body, &latest)
if err != nil {
s.log.Debug("Failed to unmarshal latest.json", "error", err)
return
}
s.mutex.Lock()
defer s.mutex.Unlock()
if strings.Contains(s.grafanaVersion, "-") {
s.latestVersion = latest.Testing
s.hasUpdate = !strings.HasPrefix(s.grafanaVersion, latest.Testing)
} else {
s.latestVersion = latest.Stable
s.hasUpdate = latest.Stable != s.grafanaVersion
}
currVersion, err1 := version.NewVersion(s.grafanaVersion)
latestVersion, err2 := version.NewVersion(s.latestVersion)
if err1 == nil && err2 == nil {
s.hasUpdate = currVersion.LessThan(latestVersion)
}
}
func (s *GrafanaService) UpdateAvailable() bool {
s.mutex.RLock()
defer s.mutex.RUnlock()
return s.hasUpdate
}
func (s *GrafanaService) LatestVersion() string {
s.mutex.RLock()
defer s.mutex.RUnlock()
return s.latestVersion
}
@@ -1,120 +0,0 @@
package updatechecker
import (
"context"
"encoding/json"
"io/ioutil"
"net/http"
"strings"
"sync"
"time"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/setting"
"github.com/hashicorp/go-version"
)
var (
httpClient = http.Client{Timeout: 10 * time.Second}
logger = log.New("update.checker")
)
type latestJSON struct {
Stable string `json:"stable"`
Testing string `json:"testing"`
}
type Service struct {
cfg *setting.Cfg
hasUpdate bool
latestVersion string
mutex sync.RWMutex
}
func ProvideService(cfg *setting.Cfg) *Service {
s := newUpdateChecker(cfg)
return s
}
func newUpdateChecker(cfg *setting.Cfg) *Service {
return &Service{
cfg: cfg,
}
}
func (s *Service) IsDisabled() bool {
return !s.cfg.CheckForUpdates
}
func (s *Service) Run(ctx context.Context) error {
s.checkForUpdates()
ticker := time.NewTicker(time.Minute * 10)
run := true
for run {
select {
case <-ticker.C:
s.checkForUpdates()
case <-ctx.Done():
run = false
}
}
return ctx.Err()
}
func (s *Service) checkForUpdates() {
resp, err := httpClient.Get("https://raw.githubusercontent.com/grafana/grafana/main/latest.json")
if err != nil {
logger.Debug("Failed to get latest.json repo from github.com", "error", err)
return
}
defer func() {
if err := resp.Body.Close(); err != nil {
logger.Warn("Failed to close response body", "err", err)
}
}()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
logger.Debug("Update check failed, reading response from github.com", "error", err)
return
}
var latest latestJSON
err = json.Unmarshal(body, &latest)
if err != nil {
logger.Debug("Failed to unmarshal latest.json", "error", err)
return
}
s.mutex.Lock()
defer s.mutex.Unlock()
if strings.Contains(s.cfg.BuildVersion, "-") {
s.latestVersion = latest.Testing
s.hasUpdate = !strings.HasPrefix(s.cfg.BuildVersion, latest.Testing)
} else {
s.latestVersion = latest.Stable
s.hasUpdate = latest.Stable != s.cfg.BuildVersion
}
currVersion, err1 := version.NewVersion(s.cfg.BuildVersion)
latestVersion, err2 := version.NewVersion(s.latestVersion)
if err1 == nil && err2 == nil {
s.hasUpdate = currVersion.LessThan(latestVersion)
}
}
func (s *Service) GrafanaUpdateAvailable() bool {
s.mutex.RLock()
defer s.mutex.RUnlock()
return s.hasUpdate
}
func (s *Service) LatestGrafanaVersion() string {
s.mutex.RLock()
defer s.mutex.RUnlock()
return s.latestVersion
}
+167
View File
@@ -0,0 +1,167 @@
package updatechecker
import (
"context"
"encoding/json"
"io/ioutil"
"net/http"
"strings"
"sync"
"time"
"github.com/hashicorp/go-version"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/setting"
)
type PluginsService struct {
availableUpdates map[string]string
enabled bool
grafanaVersion string
pluginStore plugins.Store
httpClient httpClient
mutex sync.RWMutex
log log.Logger
}
func ProvidePluginsService(cfg *setting.Cfg, pluginStore plugins.Store) *PluginsService {
return &PluginsService{
enabled: cfg.CheckForUpdates,
grafanaVersion: cfg.BuildVersion,
httpClient: &http.Client{Timeout: 10 * time.Second},
log: log.New("plugins.update.checker"),
pluginStore: pluginStore,
availableUpdates: make(map[string]string),
}
}
type httpClient interface {
Get(url string) (resp *http.Response, err error)
}
func (s *PluginsService) IsDisabled() bool {
return !s.enabled
}
func (s *PluginsService) Run(ctx context.Context) error {
s.checkForUpdates(ctx)
ticker := time.NewTicker(time.Minute * 10)
run := true
for run {
select {
case <-ticker.C:
s.checkForUpdates(ctx)
case <-ctx.Done():
run = false
}
}
return ctx.Err()
}
func (s *PluginsService) HasUpdate(ctx context.Context, pluginID string) (string, bool) {
s.mutex.RLock()
updateVers, updateAvailable := s.availableUpdates[pluginID]
s.mutex.RUnlock()
if updateAvailable {
// check if plugin has already been updated since the last invocation of `checkForUpdates`
plugin, exists := s.pluginStore.Plugin(ctx, pluginID)
if !exists {
return "", false
}
if canUpdate(plugin.Info.Version, updateVers) {
return updateVers, true
}
}
return "", false
}
func (s *PluginsService) checkForUpdates(ctx context.Context) {
s.log.Debug("Checking for updates")
localPlugins := s.pluginsEligibleForVersionCheck(ctx)
resp, err := s.httpClient.Get("https://grafana.com/api/plugins/versioncheck?slugIn=" +
s.pluginIDsCSV(localPlugins) + "&grafanaVersion=" + s.grafanaVersion)
if err != nil {
s.log.Debug("Failed to get plugins repo from grafana.com", "error", err.Error())
return
}
defer func() {
if err := resp.Body.Close(); err != nil {
s.log.Warn("Failed to close response body", "err", err)
}
}()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
s.log.Debug("Update check failed, reading response from grafana.com", "error", err.Error())
return
}
type gcomPlugin struct {
Slug string `json:"slug"`
Version string `json:"version"`
}
var gcomPlugins []gcomPlugin
err = json.Unmarshal(body, &gcomPlugins)
if err != nil {
s.log.Debug("Failed to unmarshal plugin repo, reading response from grafana.com", "error", err.Error())
return
}
availableUpdates := map[string]string{}
for _, gcomP := range gcomPlugins {
if localP, exists := localPlugins[gcomP.Slug]; exists {
if canUpdate(localP.Info.Version, gcomP.Version) {
availableUpdates[localP.ID] = gcomP.Version
}
}
}
if len(availableUpdates) > 0 {
s.mutex.Lock()
s.availableUpdates = availableUpdates
s.mutex.Unlock()
}
}
func canUpdate(v1, v2 string) bool {
ver1, err1 := version.NewVersion(v1)
if err1 != nil {
return false
}
ver2, err2 := version.NewVersion(v2)
if err2 != nil {
return false
}
return ver1.LessThan(ver2)
}
func (s *PluginsService) pluginIDsCSV(m map[string]plugins.PluginDTO) string {
var ids []string
for pluginID := range m {
ids = append(ids, pluginID)
}
return strings.Join(ids, ",")
}
func (s *PluginsService) pluginsEligibleForVersionCheck(ctx context.Context) map[string]plugins.PluginDTO {
result := make(map[string]plugins.PluginDTO)
for _, p := range s.pluginStore.Plugins(ctx) {
if p.IsCorePlugin() {
continue
}
result[p.ID] = p
}
return result
}
+225
View File
@@ -0,0 +1,225 @@
package updatechecker
import (
"context"
"io/ioutil"
"net/http"
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/plugins"
)
func TestPluginUpdateChecker_HasUpdate(t *testing.T) {
t.Run("update is available", func(t *testing.T) {
svc := PluginsService{
availableUpdates: map[string]string{
"test-ds": "1.0.0",
},
pluginStore: fakePluginStore{
plugins: map[string]plugins.PluginDTO{
"test-ds": {
JSONData: plugins.JSONData{
Info: plugins.Info{Version: "0.9.0"},
},
},
},
},
}
update, exists := svc.HasUpdate(context.Background(), "test-ds")
require.True(t, exists)
require.Equal(t, "1.0.0", update)
})
t.Run("update is not available", func(t *testing.T) {
svc := PluginsService{
availableUpdates: map[string]string{
"test-panel": "0.9.0",
"test-app": "0.0.1",
},
pluginStore: fakePluginStore{
plugins: map[string]plugins.PluginDTO{
"test-ds": {
JSONData: plugins.JSONData{
Info: plugins.Info{Version: "0.9.0"},
},
},
"test-panel": {
JSONData: plugins.JSONData{
Info: plugins.Info{Version: "0.9.0"},
},
},
"test-app": {
JSONData: plugins.JSONData{
Info: plugins.Info{Version: "0.9.0"},
},
},
},
},
}
update, exists := svc.HasUpdate(context.Background(), "test-ds")
require.False(t, exists)
require.Empty(t, update)
update, exists = svc.HasUpdate(context.Background(), "test-panel")
require.False(t, exists)
require.Empty(t, update)
update, exists = svc.HasUpdate(context.Background(), "test-app")
require.False(t, exists)
require.Empty(t, update)
})
t.Run("update is available but plugin is not in store", func(t *testing.T) {
svc := PluginsService{
availableUpdates: map[string]string{
"test-panel": "0.9.0",
},
pluginStore: fakePluginStore{
plugins: map[string]plugins.PluginDTO{
"test-ds": {
JSONData: plugins.JSONData{
Info: plugins.Info{Version: "1.0.0"},
},
},
},
},
}
update, exists := svc.HasUpdate(context.Background(), "test-panel")
require.False(t, exists)
require.Empty(t, update)
update, exists = svc.HasUpdate(context.Background(), "test-ds")
require.False(t, exists)
require.Empty(t, update)
})
}
func TestPluginUpdateChecker_checkForUpdates(t *testing.T) {
t.Run("update is available", func(t *testing.T) {
jsonResp := `[
{
"slug": "test-ds",
"version": "1.0.12"
},
{
"slug": "test-panel",
"version": "2.5.7"
},
{
"slug": "test-core-panel",
"version": "1.0.0"
}
]`
svc := PluginsService{
availableUpdates: map[string]string{
"test-app": "1.0.0",
},
pluginStore: fakePluginStore{
plugins: map[string]plugins.PluginDTO{
"test-ds": {
JSONData: plugins.JSONData{
ID: "test-ds",
Info: plugins.Info{Version: "0.9.0"},
},
},
"test-app": {
JSONData: plugins.JSONData{
ID: "test-app",
Info: plugins.Info{Version: "0.5.0"},
},
},
"test-panel": {
JSONData: plugins.JSONData{
ID: "test-panel",
Info: plugins.Info{Version: "2.5.7"},
},
},
"test-core-panel": {
Class: plugins.Core,
JSONData: plugins.JSONData{
ID: "test-core-panel",
Info: plugins.Info{Version: "0.0.1"},
},
},
},
},
httpClient: &fakeHTTPClient{
fakeResp: jsonResp,
},
log: &fakeLogger{},
}
svc.checkForUpdates(context.Background())
require.Equal(t, 1, len(svc.availableUpdates))
require.Equal(t, "1.0.12", svc.availableUpdates["test-ds"])
update, exists := svc.HasUpdate(context.Background(), "test-ds")
require.True(t, exists)
require.Equal(t, "1.0.12", update)
require.Empty(t, svc.availableUpdates["test-app"])
update, exists = svc.HasUpdate(context.Background(), "test-app")
require.False(t, exists)
require.Empty(t, update)
require.Empty(t, svc.availableUpdates["test-panel"])
update, exists = svc.HasUpdate(context.Background(), "test-panel")
require.False(t, exists)
require.Empty(t, update)
require.Empty(t, svc.availableUpdates["test-core-panel"])
})
}
type fakeHTTPClient struct {
fakeResp string
requestURL string
}
func (c *fakeHTTPClient) Get(url string) (*http.Response, error) {
c.requestURL = url
resp := &http.Response{
Body: ioutil.NopCloser(strings.NewReader(c.fakeResp)),
}
return resp, nil
}
type fakePluginStore struct {
plugins.Store
plugins map[string]plugins.PluginDTO
}
func (pr fakePluginStore) Plugin(_ context.Context, pluginID string) (plugins.PluginDTO, bool) {
p, exists := pr.plugins[pluginID]
return p, exists
}
func (pr fakePluginStore) Plugins(_ context.Context, _ ...plugins.Type) []plugins.PluginDTO {
var result []plugins.PluginDTO
for _, p := range pr.plugins {
result = append(result, p)
}
return result
}
type fakeLogger struct {
log.Logger
}
func (l *fakeLogger) Debug(_ string, _ ...interface{}) {}
func (l *fakeLogger) Warn(_ string, _ ...interface{}) {}