Provisioning: Merge watch support into live (#102618)
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
package features
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/apimachinery/pkg/watch"
|
||||
"k8s.io/client-go/dynamic"
|
||||
|
||||
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
|
||||
|
||||
"github.com/grafana/authlib/types"
|
||||
"github.com/grafana/grafana-app-sdk/logging"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data/utils/jsoniter"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/live"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver"
|
||||
"github.com/grafana/grafana/pkg/services/live/model"
|
||||
)
|
||||
|
||||
// WatchRunner will start a watch task and broadcast results
|
||||
type WatchRunner struct {
|
||||
publisher model.ChannelPublisher
|
||||
configProvider apiserver.RestConfigProvider
|
||||
|
||||
watchingMu sync.Mutex
|
||||
watching map[string]*watcher
|
||||
}
|
||||
|
||||
func NewWatchRunner(publisher model.ChannelPublisher, configProvider apiserver.RestConfigProvider) *WatchRunner {
|
||||
return &WatchRunner{
|
||||
publisher: publisher,
|
||||
configProvider: configProvider,
|
||||
watching: make(map[string]*watcher),
|
||||
}
|
||||
}
|
||||
|
||||
func (b *WatchRunner) GetHandlerForPath(_ string) (model.ChannelHandler, error) {
|
||||
return b, nil // all dashboards share the same handler
|
||||
}
|
||||
|
||||
// Valid paths look like: {version}/{resource}[={name}]/{user.uid}
|
||||
// * v0alpha1/dashboards/u12345
|
||||
// * v0alpha1/dashboards=ABCD/u12345
|
||||
func (b *WatchRunner) OnSubscribe(ctx context.Context, u identity.Requester, e model.SubscribeEvent) (model.SubscribeReply, backend.SubscribeStreamStatus, error) {
|
||||
// To make sure we do not share resources across users, in clude the UID in the path
|
||||
userID := u.GetIdentifier()
|
||||
if userID == "" {
|
||||
return model.SubscribeReply{}, backend.SubscribeStreamStatusPermissionDenied, fmt.Errorf("missing user identity")
|
||||
}
|
||||
if !strings.HasSuffix(e.Path, userID) {
|
||||
return model.SubscribeReply{}, backend.SubscribeStreamStatusPermissionDenied, fmt.Errorf("path must end with user uid (%s)", userID)
|
||||
}
|
||||
|
||||
// While testing with provisioning repositories, we will limit this to admin only
|
||||
if !u.HasRole(identity.RoleAdmin) {
|
||||
return model.SubscribeReply{}, backend.SubscribeStreamStatusPermissionDenied, fmt.Errorf("only admin users for now")
|
||||
}
|
||||
|
||||
b.watchingMu.Lock()
|
||||
defer b.watchingMu.Unlock()
|
||||
|
||||
current, ok := b.watching[e.Channel]
|
||||
if ok && !current.done {
|
||||
return model.SubscribeReply{
|
||||
JoinLeave: false,
|
||||
Presence: false,
|
||||
Recover: false,
|
||||
}, backend.SubscribeStreamStatusOK, nil
|
||||
}
|
||||
|
||||
// Try to start a watcher for this request
|
||||
gvr, name, err := parseWatchRequest(e.Channel, userID)
|
||||
if err != nil {
|
||||
return model.SubscribeReply{}, backend.SubscribeStreamStatusNotFound, err
|
||||
}
|
||||
|
||||
// Test this with only provisiong support -- then we can evaluate a broader rollout
|
||||
if gvr.Group != provisioning.GROUP {
|
||||
return model.SubscribeReply{}, backend.SubscribeStreamStatusPermissionDenied,
|
||||
fmt.Errorf("watching provisioned resources is OK allowed (for now)")
|
||||
}
|
||||
|
||||
requester := types.WithAuthInfo(context.Background(), u)
|
||||
cfg, err := b.configProvider.GetRestConfig(requester)
|
||||
if err != nil {
|
||||
return model.SubscribeReply{}, backend.SubscribeStreamStatusNotFound, err
|
||||
}
|
||||
uclient, err := dynamic.NewForConfig(cfg)
|
||||
if err != nil {
|
||||
return model.SubscribeReply{}, backend.SubscribeStreamStatusNotFound, err
|
||||
}
|
||||
client := uclient.Resource(gvr).Namespace(u.GetNamespace())
|
||||
|
||||
opts := v1.ListOptions{}
|
||||
if len(name) > 1 {
|
||||
opts.FieldSelector = "metadata.name=" + name
|
||||
}
|
||||
watch, err := client.Watch(requester, opts)
|
||||
if err != nil {
|
||||
return model.SubscribeReply{}, backend.SubscribeStreamStatusNotFound, err
|
||||
}
|
||||
|
||||
current = &watcher{
|
||||
orgId: u.GetOrgID(),
|
||||
channel: e.Channel,
|
||||
publisher: b.publisher,
|
||||
watch: watch,
|
||||
}
|
||||
|
||||
b.watching[e.Channel] = current
|
||||
go current.run(ctx)
|
||||
|
||||
return model.SubscribeReply{
|
||||
JoinLeave: false, // need unsubscribe envents
|
||||
Presence: false,
|
||||
Recover: false,
|
||||
}, backend.SubscribeStreamStatusOK, nil
|
||||
}
|
||||
|
||||
func parseWatchRequest(channel string, user string) (gvr schema.GroupVersionResource, name string, err error) {
|
||||
addr, err := live.ParseChannel(channel)
|
||||
if err != nil {
|
||||
return gvr, "", err
|
||||
}
|
||||
|
||||
parts := strings.Split(addr.Path, "/")
|
||||
if len(parts) != 3 {
|
||||
return gvr, "", fmt.Errorf("expecting path: {version}/{resource}={name}/{user}")
|
||||
}
|
||||
if parts[2] != user {
|
||||
return gvr, "", fmt.Errorf("expecting user suffix: %s", user)
|
||||
}
|
||||
|
||||
resource := strings.Split(parts[1], "=")
|
||||
gvr = schema.GroupVersionResource{
|
||||
Group: addr.Namespace,
|
||||
Version: parts[0],
|
||||
Resource: resource[0],
|
||||
}
|
||||
if len(resource) > 1 {
|
||||
name = resource[1]
|
||||
}
|
||||
return gvr, name, nil
|
||||
}
|
||||
|
||||
// OnPublish is called when a client wants to broadcast on the websocket
|
||||
func (b *WatchRunner) OnPublish(_ context.Context, u identity.Requester, e model.PublishEvent) (model.PublishReply, backend.PublishStreamStatus, error) {
|
||||
return model.PublishReply{}, backend.PublishStreamStatusNotFound, fmt.Errorf("watch does not support publish")
|
||||
}
|
||||
|
||||
type watcher struct {
|
||||
orgId int64
|
||||
channel string
|
||||
publisher model.ChannelPublisher
|
||||
done bool
|
||||
watch watch.Interface
|
||||
}
|
||||
|
||||
func (b *watcher) run(ctx context.Context) {
|
||||
logger := logging.FromContext(ctx).With("channel", b.channel)
|
||||
|
||||
ch := b.watch.ResultChan()
|
||||
for {
|
||||
select {
|
||||
// This is sent when there are no longer any subscriptions
|
||||
case <-ctx.Done():
|
||||
logger.Info("context done", "channel", b.channel)
|
||||
b.watch.Stop()
|
||||
b.done = true
|
||||
return
|
||||
|
||||
// Each watch event
|
||||
case event, ok := <-ch:
|
||||
if !ok {
|
||||
logger.Info("watch stream broken", "channel", b.channel)
|
||||
b.watch.Stop()
|
||||
b.done = true // will force reconnect from the frontend
|
||||
return
|
||||
}
|
||||
|
||||
cfg := jsoniter.ConfigCompatibleWithStandardLibrary
|
||||
stream := cfg.BorrowStream(nil)
|
||||
defer cfg.ReturnStream(stream)
|
||||
|
||||
// regular json.Marshal() uses upper case
|
||||
stream.WriteObjectStart()
|
||||
stream.WriteObjectField("type")
|
||||
stream.WriteString(string(event.Type))
|
||||
stream.WriteMore()
|
||||
stream.WriteObjectField("object")
|
||||
stream.WriteVal(event.Object)
|
||||
stream.WriteObjectEnd()
|
||||
|
||||
buf := stream.Buffer()
|
||||
data := make([]byte, len(buf))
|
||||
copy(data, buf)
|
||||
|
||||
err := b.publisher(b.orgId, b.channel, data)
|
||||
if err != nil {
|
||||
logger.Error("publish error", "channel", b.channel, "err", err)
|
||||
b.watch.Stop()
|
||||
b.done = true // will force reconnect from the frontend
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/annotations"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver"
|
||||
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
"github.com/grafana/grafana/pkg/services/datasources"
|
||||
@@ -79,7 +80,7 @@ func ProvideService(plugCtxProvider *plugincontext.Provider, cfg *setting.Cfg, r
|
||||
dataSourceCache datasources.CacheService, sqlStore db.DB, secretsService secrets.Service,
|
||||
usageStatsService usagestats.Service, queryDataService query.Service, toggles featuremgmt.FeatureToggles,
|
||||
accessControl accesscontrol.AccessControl, dashboardService dashboards.DashboardService, annotationsRepo annotations.Repository,
|
||||
orgService org.Service) (*GrafanaLive, error) {
|
||||
orgService org.Service, configProvider apiserver.RestConfigProvider) (*GrafanaLive, error) {
|
||||
g := &GrafanaLive{
|
||||
Cfg: cfg,
|
||||
Features: toggles,
|
||||
@@ -191,6 +192,11 @@ func ProvideService(plugCtxProvider *plugincontext.Provider, cfg *setting.Cfg, r
|
||||
g.GrafanaScope.Features["dashboard"] = dash
|
||||
g.GrafanaScope.Features["broadcast"] = features.NewBroadcastRunner(g.storage)
|
||||
|
||||
// Testing watch with just the provisioning support -- this will be removed when it is well validated
|
||||
if toggles.IsEnabledGlobally(featuremgmt.FlagProvisioning) {
|
||||
g.GrafanaScope.Features["watch"] = features.NewWatchRunner(g.Publish, configProvider)
|
||||
}
|
||||
|
||||
g.surveyCaller = survey.NewCaller(managedStreamRunner, node)
|
||||
err = g.surveyCaller.SetupHandlers()
|
||||
if err != nil {
|
||||
@@ -889,6 +895,8 @@ func (g *GrafanaLive) GetChannelHandlerFactory(ctx context.Context, user identit
|
||||
switch scope {
|
||||
case live.ScopeGrafana:
|
||||
return g.handleGrafanaScope(user, namespace)
|
||||
case "watch": // TODO: live.ScopeWatch: update 275 https://github.com/grafana/grafana-plugin-sdk-go/releases
|
||||
return g.handleWatchScope()
|
||||
case live.ScopePlugin:
|
||||
return g.handlePluginScope(ctx, user, namespace)
|
||||
case live.ScopeDatasource:
|
||||
@@ -907,6 +915,13 @@ func (g *GrafanaLive) handleGrafanaScope(_ identity.Requester, namespace string)
|
||||
return nil, fmt.Errorf("unknown feature: %q", namespace)
|
||||
}
|
||||
|
||||
func (g *GrafanaLive) handleWatchScope() (model.ChannelHandlerFactory, error) {
|
||||
if p, ok := g.GrafanaScope.Features["watch"]; ok {
|
||||
return p, nil
|
||||
}
|
||||
return nil, fmt.Errorf("watch not registered")
|
||||
}
|
||||
|
||||
func (g *GrafanaLive) handlePluginScope(ctx context.Context, _ identity.Requester, namespace string) (model.ChannelHandlerFactory, error) {
|
||||
streamHandler, err := g.getStreamPlugin(ctx, namespace)
|
||||
if err != nil {
|
||||
|
||||
@@ -36,7 +36,11 @@ func Test_provideLiveService_RedisUnavailable(t *testing.T) {
|
||||
nil,
|
||||
&usagestats.UsageStatsMock{T: t},
|
||||
nil,
|
||||
featuremgmt.WithFeatures(), acimpl.ProvideAccessControl(featuremgmt.WithFeatures()), &dashboards.FakeDashboardService{}, annotationstest.NewFakeAnnotationsRepo(), nil)
|
||||
featuremgmt.WithFeatures(),
|
||||
acimpl.ProvideAccessControl(featuremgmt.WithFeatures()),
|
||||
&dashboards.FakeDashboardService{},
|
||||
annotationstest.NewFakeAnnotationsRepo(),
|
||||
nil, nil)
|
||||
|
||||
// Proceeds without live HA if redis is unavaialble
|
||||
require.NoError(t, err)
|
||||
|
||||
Reference in New Issue
Block a user