Plugins: Angular detector: Remote patterns fetching (#69843)

* Plugins: Angular detector: Remote patterns fetching

* Renamed PatternType to GCOMPatternType

* Renamed files

* Renamed more files

* Moved files again

* Add type checks, unexport GCOM structs

* Cache failures, update log messages, fix GCOM URL

* Fail silently for unknown pattern types, update docstrings

* Fix tests

* Rename gcomPattern.Value to gcomPattern.Pattern

* Refactoring

* Add FlagPluginsRemoteAngularDetectionPatterns feature flag

* Fix tests

* Re-generate feature flags

* Add TestProvideInspector, renamed TestDefaultStaticDetectorsInspector

* Add TestProvideInspector

* Add TestContainsBytesDetector and TestRegexDetector

* Renamed getter to provider

* More tests

* TestStaticDetectorsProvider, TestSequenceDetectorsProvider

* GCOM tests

* Lint

* Made detector.detect unexported, updated docstrings

* Allow changing grafana.com URL

* Fix API path, add more logs

* Update tryUpdateRemoteDetectors docstring

* Use angulardetector http client

* Return false, nil if module.js does not exist

* Chore: Split angualrdetector into angularinspector and angulardetector packages

Moved files around, changed references and fixed tests:
- Split the old angulardetector package into angular/angulardetector and angular/angularinspector
- angulardetector provides the detection structs/interfaces (Detector, DetectorsProvider...)
- angularinspector provides the actual angular detection service used directly in pluginsintegration
- Exported most of the stuff that was private and now put into angulardetector, as it is not required by angularinspector

* Renamed detector.go -> angulardetector.go and inspector.go -> angularinspector.go

Forgot to rename those two files to match the package's names

* Renamed angularinspector.ProvideInspector to angularinspector.ProvideService

* Renamed "harcoded" to "static" and "remote" to "dynamic"

from PR review, matches the same naming schema used for signing keys fetching

* Fix merge conflict on updated angular patterns

* Removed GCOM cache

* Renamed Detect to DetectAngular and Detector to AngularDetector

* Fix call to NewGCOMDetectorsProvider in newDynamicInspector

* Removed unused test function newError500GCOMScenario

* Added angularinspector service definition in pluginsintegration

* Moved dynamic inspector into pluginsintegration

* Move gcom angulardetectorsprovider into pluginsintegration

* Log errUnknownPatternType at debug level

* re-generate feature flags

* fix error log
This commit is contained in:
Giuseppe Guerra
2023-06-26 15:33:21 +02:00
committed by GitHub
parent 903af7e29c
commit cca9d89733
25 changed files with 949 additions and 282 deletions
@@ -0,0 +1,157 @@
package angulardetector
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"regexp"
"time"
"github.com/grafana/grafana-plugin-sdk-go/backend/httpclient"
"github.com/grafana/grafana/pkg/plugins/log"
"github.com/grafana/grafana/pkg/plugins/manager/loader/angular/angulardetector"
)
const (
// gcomAngularPatternsPath is the relative path to the GCOM API handler that returns angular detection patterns.
gcomAngularPatternsPath = "/api/plugins/angular_patterns"
)
var _ angulardetector.DetectorsProvider = &GCOMDetectorsProvider{}
// GCOMDetectorsProvider is a DetectorsProvider which fetches patterns from GCOM.
type GCOMDetectorsProvider struct {
log log.Logger
httpClient *http.Client
baseURL string
}
// NewGCOMDetectorsProvider returns a new GCOMDetectorsProvider.
// baseURL is the GCOM base url, without /api and without a trailing slash (e.g.: https://grafana.com)
func NewGCOMDetectorsProvider(baseURL string) (angulardetector.DetectorsProvider, error) {
cl, err := httpclient.New()
if err != nil {
return nil, fmt.Errorf("httpclient new: %w", err)
}
return &GCOMDetectorsProvider{
log: log.New("plugins.angulardetector.gcom"),
baseURL: baseURL,
httpClient: cl,
}, nil
}
// ProvideDetectors gets the dynamic angular detectors from the remote source.
// If an error occurs, the function fails silently by logging an error, and it returns nil.
func (p *GCOMDetectorsProvider) ProvideDetectors(ctx context.Context) []angulardetector.AngularDetector {
patterns, err := p.fetch(ctx)
if err != nil {
p.log.Warn("Could not fetch remote angular patterns", "error", err)
return nil
}
detectors, err := p.patternsToDetectors(patterns)
if err != nil {
p.log.Warn("Could not convert angular patterns to angularDetectors", "error", err)
return nil
}
return detectors
}
// fetch fetches the angular patterns from GCOM and returns them as gcomPatterns.
// Call angularDetectors() on the returned value to get the corresponding angular detectors.
func (p *GCOMDetectorsProvider) fetch(ctx context.Context) (gcomPatterns, error) {
st := time.Now()
reqURL, err := url.JoinPath(p.baseURL, gcomAngularPatternsPath)
if err != nil {
return nil, fmt.Errorf("url joinpath: %w", err)
}
p.log.Debug("Fetching dynamic angular detection patterns", "url", reqURL)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return nil, fmt.Errorf("new request with context: %w", err)
}
resp, err := p.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("http do: %w", err)
}
defer func() {
if closeErr := resp.Body.Close(); closeErr != nil {
p.log.Error("response body close error", "error", err)
}
}()
var out gcomPatterns
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, fmt.Errorf("json decode: %w", err)
}
p.log.Debug("Fetched dynamic angular detection patterns", "patterns", len(out), "duration", time.Since(st))
return out, nil
}
// patternsToDetectors converts a slice of gcomPattern into a slice of angulardetector.AngularDetector, by calling
// angularDetector() on each gcomPattern.
func (p *GCOMDetectorsProvider) patternsToDetectors(patterns gcomPatterns) ([]angulardetector.AngularDetector, error) {
var finalErr error
detectors := make([]angulardetector.AngularDetector, 0, len(patterns))
for _, pattern := range patterns {
d, err := pattern.angularDetector()
if err != nil {
// Fail silently in case of an errUnknownPatternType.
// This allows us to introduce new pattern types without breaking old Grafana versions
if errors.Is(err, errUnknownPatternType) {
p.log.Debug("Unknown angular pattern", "name", pattern.Name, "type", pattern.Type, "error", err)
continue
}
// Other error, do not ignore it
finalErr = errors.Join(finalErr, err)
}
detectors = append(detectors, d)
}
if finalErr != nil {
return nil, finalErr
}
return detectors, nil
}
// gcomPatternType is a pattern type returned by the GCOM API.
type gcomPatternType string
const (
gcomPatternTypeContains gcomPatternType = "contains"
gcomPatternTypeRegex gcomPatternType = "regex"
)
// errUnknownPatternType is returned when a pattern type is not known.
var errUnknownPatternType = errors.New("unknown pattern type")
// gcomPattern is an Angular detection pattern returned by the GCOM API.
type gcomPattern struct {
Name string
Pattern string
Type gcomPatternType
}
// angularDetector converts a gcomPattern into an AngularDetector, based on its Type.
// If a pattern type is unknown, it returns an error wrapping errUnknownPatternType.
func (p *gcomPattern) angularDetector() (angulardetector.AngularDetector, error) {
switch p.Type {
case gcomPatternTypeContains:
return &angulardetector.ContainsBytesDetector{Pattern: []byte(p.Pattern)}, nil
case gcomPatternTypeRegex:
re, err := regexp.Compile(p.Pattern)
if err != nil {
return nil, fmt.Errorf("%q regexp compile: %w", p.Pattern, err)
}
return &angulardetector.RegexDetector{Regex: re}, nil
}
return nil, fmt.Errorf("%q: %w", p.Type, errUnknownPatternType)
}
// gcomPatterns is a slice of gcomPattern s.
type gcomPatterns []gcomPattern
@@ -0,0 +1,144 @@
package angulardetector
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/plugins/manager/loader/angular/angulardetector"
)
var mockGCOMResponse = []byte(`[{
"name": "PanelCtrl",
"type": "contains",
"pattern": "PanelCtrl"
},
{
"name": "QueryCtrl",
"type": "regex",
"pattern": "[\"']QueryCtrl[\"']"
}]`)
func mockGCOMHTTPHandlerFunc(writer http.ResponseWriter, request *http.Request) {
if request.URL.Path != "/api/plugins/angular_patterns" {
writer.WriteHeader(http.StatusNotFound)
return
}
_, _ = writer.Write(mockGCOMResponse)
}
func checkMockGCOMResponse(t *testing.T, detectors []angulardetector.AngularDetector) {
require.Len(t, detectors, 2)
d, ok := detectors[0].(*angulardetector.ContainsBytesDetector)
require.True(t, ok)
require.Equal(t, []byte(`PanelCtrl`), d.Pattern)
rd, ok := detectors[1].(*angulardetector.RegexDetector)
require.True(t, ok)
require.Equal(t, `["']QueryCtrl["']`, rd.Regex.String())
}
type gcomScenario struct {
gcomHTTPHandlerFunc http.HandlerFunc
gcomHTTPCalls int
}
func (s *gcomScenario) newHTTPTestServer() *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
s.gcomHTTPCalls++
s.gcomHTTPHandlerFunc(w, r)
}))
}
func newDefaultGCOMScenario() *gcomScenario {
return &gcomScenario{gcomHTTPHandlerFunc: mockGCOMHTTPHandlerFunc}
}
func TestGCOMDetectorsProvider(t *testing.T) {
t.Run("returns value returned from gcom api", func(t *testing.T) {
scenario := newDefaultGCOMScenario()
srv := scenario.newHTTPTestServer()
t.Cleanup(srv.Close)
gcomProvider, err := NewGCOMDetectorsProvider(srv.URL)
require.NoError(t, err)
detectors := gcomProvider.ProvideDetectors(context.Background())
require.Equal(t, 1, scenario.gcomHTTPCalls, "gcom api should be called")
checkMockGCOMResponse(t, detectors)
})
t.Run("error handling", func(t *testing.T) {
for _, tc := range []struct {
*gcomScenario
name string
}{
{name: "http error 500", gcomScenario: &gcomScenario{
gcomHTTPHandlerFunc: func(writer http.ResponseWriter, request *http.Request) {
writer.WriteHeader(http.StatusInternalServerError)
},
}},
{name: "invalid json", gcomScenario: &gcomScenario{
gcomHTTPHandlerFunc: func(writer http.ResponseWriter, request *http.Request) {
_, _ = writer.Write([]byte(`not json`))
},
}},
{name: "invalid regex", gcomScenario: &gcomScenario{
gcomHTTPHandlerFunc: func(writer http.ResponseWriter, request *http.Request) {
_, _ = writer.Write([]byte(`[{"name": "test", "type": "regex", "pattern": "((("}]`))
},
}},
} {
t.Run(tc.name, func(t *testing.T) {
srv := tc.newHTTPTestServer()
t.Cleanup(srv.Close)
gcomProvider, err := NewGCOMDetectorsProvider(srv.URL)
require.NoError(t, err)
detectors := gcomProvider.ProvideDetectors(context.Background())
require.Equal(t, 1, tc.gcomHTTPCalls, "gcom should be called")
require.Empty(t, detectors, "returned AngularDetectors should be empty")
})
}
})
t.Run("handles gcom timeout", func(t *testing.T) {
gcomScenario := &gcomScenario{
gcomHTTPHandlerFunc: func(writer http.ResponseWriter, request *http.Request) {
time.Sleep(time.Second * 1)
_, _ = writer.Write([]byte(`[{"name": "test", "type": "regex", "pattern": "((("}]`))
},
}
srv := gcomScenario.newHTTPTestServer()
t.Cleanup(srv.Close)
gcomProvider, err := NewGCOMDetectorsProvider(srv.URL)
require.NoError(t, err)
// Expired context
ctx, canc := context.WithTimeout(context.Background(), time.Second*-1)
defer canc()
detectors := gcomProvider.ProvideDetectors(ctx)
require.Zero(t, gcomScenario.gcomHTTPCalls, "gcom should be not called due to request timing out")
require.Empty(t, detectors, "returned AngularDetectors should be empty")
})
t.Run("unknown pattern types do not break decoding", func(t *testing.T) {
// Tests that we can introduce new pattern types in the future without breaking old Grafana versions.
scenario := gcomScenario{gcomHTTPHandlerFunc: func(writer http.ResponseWriter, request *http.Request) {
_, _ = writer.Write([]byte(`[
{"name": "PanelCtrl", "type": "contains", "pattern": "PanelCtrl"},
{"name": "Another", "type": "unknown", "pattern": "PanelCtrl"}
]`))
}}
srv := scenario.newHTTPTestServer()
t.Cleanup(srv.Close)
gcomProvider, err := NewGCOMDetectorsProvider(srv.URL)
require.NoError(t, err)
detectors := gcomProvider.ProvideDetectors(context.Background())
require.Equal(t, 1, scenario.gcomHTTPCalls, "gcom should be called")
require.Len(t, detectors, 1, "should have decoded only 1 AngularDetector")
d, ok := detectors[0].(*angulardetector.ContainsBytesDetector)
require.True(t, ok, "decoded pattern should be of the correct type")
require.Equal(t, []byte("PanelCtrl"), d.Pattern, "decoded value for known pattern should be correct")
})
}
@@ -0,0 +1,45 @@
package angularinspector
import (
"fmt"
"github.com/grafana/grafana/pkg/plugins/config"
"github.com/grafana/grafana/pkg/plugins/manager/loader/angular/angulardetector"
"github.com/grafana/grafana/pkg/plugins/manager/loader/angular/angularinspector"
"github.com/grafana/grafana/pkg/services/featuremgmt"
pAngularDetector "github.com/grafana/grafana/pkg/services/pluginsintegration/angulardetector"
)
type Service struct {
angularinspector.Inspector
}
// newDynamicInspector returns the default dynamic Inspector, which is a PatternsListInspector that will:
// 1. Try to get the Angular detectors from GCOM
// 2. If it fails, it will use the static (hardcoded) detections provided by defaultDetectors.
func newDynamicInspector(cfg *config.Cfg) (angularinspector.Inspector, error) {
dynamicProvider, err := pAngularDetector.NewGCOMDetectorsProvider(cfg.GrafanaComURL)
if err != nil {
return nil, fmt.Errorf("NewGCOMDetectorsProvider: %w", err)
}
return &angularinspector.PatternsListInspector{
DetectorsProvider: angulardetector.SequenceDetectorsProvider{
dynamicProvider,
angularinspector.NewDefaultStaticDetectorsProvider(),
},
}, nil
}
func ProvideService(cfg *config.Cfg) (*Service, error) {
var underlying angularinspector.Inspector
var err error
if cfg.Features != nil && cfg.Features.IsEnabled(featuremgmt.FlagPluginsDynamicAngularDetectionPatterns) {
underlying, err = newDynamicInspector(cfg)
} else {
underlying, err = angularinspector.NewStaticInspector()
}
if err != nil {
return nil, err
}
return &Service{underlying}, nil
}
@@ -0,0 +1,42 @@
package angularinspector
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/plugins/config"
"github.com/grafana/grafana/pkg/plugins/manager/loader/angular/angulardetector"
"github.com/grafana/grafana/pkg/plugins/manager/loader/angular/angularinspector"
"github.com/grafana/grafana/pkg/services/featuremgmt"
pAngularDetector "github.com/grafana/grafana/pkg/services/pluginsintegration/angulardetector"
)
func TestProvideService(t *testing.T) {
t.Run("uses hardcoded inspector if feature flag is not present", func(t *testing.T) {
inspector, err := ProvideService(&config.Cfg{
Features: featuremgmt.WithFeatures(),
})
require.NoError(t, err)
require.IsType(t, inspector.Inspector, &angularinspector.PatternsListInspector{})
patternsListInspector := inspector.Inspector.(*angularinspector.PatternsListInspector)
detectors := patternsListInspector.DetectorsProvider.ProvideDetectors(context.Background())
require.NotEmpty(t, detectors, "provided detectors should not be empty")
})
t.Run("uses dynamic inspector with hardcoded fallback if feature flag is present", func(t *testing.T) {
inspector, err := ProvideService(&config.Cfg{
Features: featuremgmt.WithFeatures(featuremgmt.FlagPluginsDynamicAngularDetectionPatterns),
})
require.NoError(t, err)
require.IsType(t, inspector.Inspector, &angularinspector.PatternsListInspector{})
require.IsType(t, inspector.Inspector.(*angularinspector.PatternsListInspector).DetectorsProvider, angulardetector.SequenceDetectorsProvider{})
seq := inspector.Inspector.(*angularinspector.PatternsListInspector).DetectorsProvider.(angulardetector.SequenceDetectorsProvider)
require.Len(t, seq, 2, "should return the correct number of providers")
require.IsType(t, seq[0], &pAngularDetector.GCOMDetectorsProvider{}, "first AngularDetector provided should be gcom")
require.IsType(t, seq[1], &angulardetector.StaticDetectorsProvider{}, "second AngularDetector provided should be static")
staticDetectors := seq[1].ProvideDetectors(context.Background())
require.NotEmpty(t, staticDetectors, "provided static detectors should not be empty")
})
}
@@ -42,6 +42,7 @@ func ProvideConfig(settingProvider setting.Provider, grafanaCfg *setting.Cfg, fe
tracingCfg,
featuremgmt.ProvideToggles(features),
grafanaCfg.AngularSupportEnabled,
grafanaCfg.GrafanaComURL,
), nil
}
@@ -12,7 +12,7 @@ import (
"github.com/grafana/grafana/pkg/plugins/manager/client"
"github.com/grafana/grafana/pkg/plugins/manager/filestore"
"github.com/grafana/grafana/pkg/plugins/manager/loader"
"github.com/grafana/grafana/pkg/plugins/manager/loader/angulardetector"
pAngularInspector "github.com/grafana/grafana/pkg/plugins/manager/loader/angular/angularinspector"
"github.com/grafana/grafana/pkg/plugins/manager/loader/assetpath"
"github.com/grafana/grafana/pkg/plugins/manager/loader/finder"
"github.com/grafana/grafana/pkg/plugins/manager/process"
@@ -25,6 +25,7 @@ import (
"github.com/grafana/grafana/pkg/services/caching"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/oauthtoken"
"github.com/grafana/grafana/pkg/services/pluginsintegration/angularinspector"
"github.com/grafana/grafana/pkg/services/pluginsintegration/clientmiddleware"
"github.com/grafana/grafana/pkg/services/pluginsintegration/config"
"github.com/grafana/grafana/pkg/services/pluginsintegration/keyretriever"
@@ -52,7 +53,8 @@ var WireSet = wire.NewSet(
coreplugin.ProvideCoreRegistry,
pluginscdn.ProvideService,
assetpath.ProvideService,
angulardetector.ProvideService,
angularinspector.ProvideService,
wire.Bind(new(pAngularInspector.Inspector), new(*angularinspector.Service)),
loader.ProvideService,
wire.Bind(new(loader.Service), new(*loader.Loader)),
wire.Bind(new(plugins.ErrorResolver), new(*loader.Loader)),