From 7b492d7e1610da99460cb121ec542c5b755fe6d1 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Fri, 25 Apr 2025 12:24:25 +0300 Subject: [PATCH] FEMT: Add feature toggle and expose the service in regular grafana (#104428) --- .../src/types/featureToggles.gen.ts | 4 + pkg/api/api.go | 9 ++ pkg/middleware/csp.go | 4 +- pkg/server/module_server.go | 22 +++- pkg/services/featuremgmt/registry.go | 8 +- pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.go | 4 + pkg/services/featuremgmt/toggles_gen.json | 25 ++++ pkg/services/frontend/frontend_service.go | 51 +++------ pkg/services/frontend/index.go | 108 ++++++++++++++++++ pkg/services/frontend/index.html | 33 ++++++ 11 files changed, 225 insertions(+), 44 deletions(-) create mode 100644 pkg/services/frontend/index.go create mode 100644 pkg/services/frontend/index.html diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index c86709ef862..513f38f4fdb 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -1014,6 +1014,10 @@ export interface FeatureToggles { */ pluginsAutoUpdate?: boolean; /** + * Register MT frontend + */ + multiTenantFrontend?: boolean; + /** * Enables the alerting list view v2 preview toggle */ alertingListViewV2PreviewToggle?: boolean; diff --git a/pkg/api/api.go b/pkg/api/api.go index 14218d00299..fbcded89e6d 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -48,6 +48,7 @@ import ( "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/frontend" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginaccesscontrol" publicdashboardsapi "github.com/grafana/grafana/pkg/services/publicdashboards/api" @@ -85,6 +86,14 @@ func (hs *HTTPServer) registerRoutes() { r.Get("/login", hs.LoginView) r.Get("/invite/:code", hs.Index) + if hs.Features.IsEnabledGlobally(featuremgmt.FlagMultiTenantFrontend) { + index, err := frontend.NewIndexProvider(hs.Cfg, hs.License) + if err != nil { + panic(err) // ??? + } + r.Get("/mtfe", index.HandleRequest) + } + // authed views r.Get("/", reqSignedIn, hs.Index) r.Get("/profile/", reqSignedInNoAnonymous, hs.Index) diff --git a/pkg/middleware/csp.go b/pkg/middleware/csp.go index ebfd54c4450..c8c41a9468f 100644 --- a/pkg/middleware/csp.go +++ b/pkg/middleware/csp.go @@ -31,7 +31,7 @@ func ContentSecurityPolicy(cfg *setting.Cfg, logger log.Logger) func(http.Handle func nonceMiddleware(next http.Handler, logger log.Logger) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { ctx := contexthandler.FromContext(req.Context()) - nonce, err := generateNonce() + nonce, err := GenerateNonce() if err != nil { logger.Error("Failed to generate CSP nonce", "err", err) ctx.JsonApiErr(500, "Failed to generate CSP nonce", err) @@ -68,7 +68,7 @@ func ReplacePolicyVariables(policyTemplate, appURL, nonce string) string { return policy } -func generateNonce() (string, error) { +func GenerateNonce() (string, error) { var buf [16]byte if _, err := io.ReadFull(rand.Reader, buf[:]); err != nil { return "", err diff --git a/pkg/server/module_server.go b/pkg/server/module_server.go index 7142b4b8c63..defea7e9a08 100644 --- a/pkg/server/module_server.go +++ b/pkg/server/module_server.go @@ -9,6 +9,8 @@ import ( "strconv" "sync" + "github.com/prometheus/client_golang/prometheus" + "github.com/grafana/dskit/services" "github.com/grafana/grafana/pkg/api" "github.com/grafana/grafana/pkg/infra/log" @@ -16,16 +18,24 @@ import ( "github.com/grafana/grafana/pkg/services/authz" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/frontend" + "github.com/grafana/grafana/pkg/services/licensing" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/storage/unified/resource" "github.com/grafana/grafana/pkg/storage/unified/sql" - "github.com/prometheus/client_golang/prometheus" ) // NewModule returns an instance of a ModuleServer, responsible for managing // dskit modules (services). -func NewModule(opts Options, apiOpts api.ServerOptions, features featuremgmt.FeatureToggles, cfg *setting.Cfg, storageMetrics *resource.StorageMetrics, indexMetrics *resource.BleveIndexMetrics, promGatherer prometheus.Gatherer) (*ModuleServer, error) { - s, err := newModuleServer(opts, apiOpts, features, cfg, storageMetrics, indexMetrics, promGatherer) +func NewModule(opts Options, + apiOpts api.ServerOptions, + features featuremgmt.FeatureToggles, + cfg *setting.Cfg, + storageMetrics *resource.StorageMetrics, + indexMetrics *resource.BleveIndexMetrics, + promGatherer prometheus.Gatherer, + license licensing.Licensing, +) (*ModuleServer, error) { + s, err := newModuleServer(opts, apiOpts, features, cfg, storageMetrics, indexMetrics, promGatherer, license) if err != nil { return nil, err } @@ -37,7 +47,7 @@ func NewModule(opts Options, apiOpts api.ServerOptions, features featuremgmt.Fea return s, nil } -func newModuleServer(opts Options, apiOpts api.ServerOptions, features featuremgmt.FeatureToggles, cfg *setting.Cfg, storageMetrics *resource.StorageMetrics, indexMetrics *resource.BleveIndexMetrics, promGatherer prometheus.Gatherer) (*ModuleServer, error) { +func newModuleServer(opts Options, apiOpts api.ServerOptions, features featuremgmt.FeatureToggles, cfg *setting.Cfg, storageMetrics *resource.StorageMetrics, indexMetrics *resource.BleveIndexMetrics, promGatherer prometheus.Gatherer, license licensing.Licensing) (*ModuleServer, error) { rootCtx, shutdownFn := context.WithCancel(context.Background()) s := &ModuleServer{ @@ -56,6 +66,7 @@ func newModuleServer(opts Options, apiOpts api.ServerOptions, features featuremg storageMetrics: storageMetrics, indexMetrics: indexMetrics, promGatherer: promGatherer, + license: license, } return s, nil @@ -79,6 +90,7 @@ type ModuleServer struct { mtx sync.Mutex storageMetrics *resource.StorageMetrics indexMetrics *resource.BleveIndexMetrics + license licensing.Licensing pidFile string version string @@ -153,7 +165,7 @@ func (s *ModuleServer) Run() error { }) m.RegisterModule(modules.FrontendServer, func() (services.Service, error) { - return frontend.ProvideFrontendService(s.cfg, s.promGatherer) + return frontend.ProvideFrontendService(s.cfg, s.promGatherer, s.license) }) m.RegisterModule(modules.All, nil) diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 682068ca1dc..0c70783efcf 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1738,13 +1738,19 @@ var ( FrontendOnly: true, }, { - Name: "pluginsAutoUpdate", Description: "Enables auto-updating of users installed plugins", Stage: FeatureStageExperimental, FrontendOnly: false, Owner: grafanaPluginsPlatformSquad, }, + { + Name: "multiTenantFrontend", + Description: "Register MT frontend", + Stage: FeatureStageExperimental, + FrontendOnly: false, + Owner: grafanaFrontendPlatformSquad, + }, { Name: "alertingListViewV2PreviewToggle", Description: "Enables the alerting list view v2 preview toggle", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index e29e7610d6e..9f6642437a7 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -228,6 +228,7 @@ unifiedNavbars,GA,@grafana/plugins-platform-backend,false,false,true logsPanelControls,preview,@grafana/observability-logs,false,false,true metricsFromProfiles,experimental,@grafana/observability-traces-and-profiling,false,false,true pluginsAutoUpdate,experimental,@grafana/plugins-platform-backend,false,false,false +multiTenantFrontend,experimental,@grafana/grafana-frontend-platform,false,false,false alertingListViewV2PreviewToggle,privatePreview,@grafana/alerting-squad,false,false,true alertRuleUseFiredAtForStartsAt,experimental,@grafana/alerting-squad,false,false,false alertingBulkActionsInUI,GA,@grafana/alerting-squad,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index c86dea8143f..4d577a49a4c 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -923,6 +923,10 @@ const ( // Enables auto-updating of users installed plugins FlagPluginsAutoUpdate = "pluginsAutoUpdate" + // FlagMultiTenantFrontend + // Register MT frontend + FlagMultiTenantFrontend = "multiTenantFrontend" + // FlagAlertingListViewV2PreviewToggle // Enables the alerting list view v2 preview toggle FlagAlertingListViewV2PreviewToggle = "alertingListViewV2PreviewToggle" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index c20b13fad3e..82f7b6df36b 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -1976,6 +1976,18 @@ "codeowner": "@grafana/alerting-squad" } }, + { + "metadata": { + "name": "multiTenantFrontend", + "resourceVersion": "1745438197175", + "creationTimestamp": "2025-04-23T19:56:37Z" + }, + "spec": { + "description": "Register MT frontend", + "stage": "experimental", + "codeowner": "@grafana/grafana-frontend-platform" + } + }, { "metadata": { "name": "multiTenantTempCredentials", @@ -1989,6 +2001,19 @@ "hideFromDocs": true } }, + { + "metadata": { + "name": "multitenantFrontend", + "resourceVersion": "1745438122785", + "creationTimestamp": "2025-04-23T19:55:22Z", + "deletionTimestamp": "2025-04-23T19:56:37Z" + }, + "spec": { + "description": "Register MT frontend", + "stage": "experimental", + "codeowner": "@grafana/grafana-frontend-platform" + } + }, { "metadata": { "name": "mysqlAnsiQuotes", diff --git a/pkg/services/frontend/frontend_service.go b/pkg/services/frontend/frontend_service.go index 3658d4097c0..81b67859ec2 100644 --- a/pkg/services/frontend/frontend_service.go +++ b/pkg/services/frontend/frontend_service.go @@ -6,11 +6,13 @@ import ( "net/http" "time" - "github.com/grafana/dskit/services" - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/setting" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" + + "github.com/grafana/dskit/services" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/licensing" + "github.com/grafana/grafana/pkg/setting" ) type frontendService struct { @@ -20,13 +22,21 @@ type frontendService struct { log log.Logger errChan chan error promGatherer prometheus.Gatherer + + index *IndexProvider } -func ProvideFrontendService(cfg *setting.Cfg, promGatherer prometheus.Gatherer) (*frontendService, error) { +func ProvideFrontendService(cfg *setting.Cfg, promGatherer prometheus.Gatherer, license licensing.Licensing) (*frontendService, error) { + index, err := NewIndexProvider(cfg, license) + if err != nil { + return nil, err + } + s := &frontendService{ cfg: cfg, log: log.New("frontend-server"), promGatherer: promGatherer, + index: index, } s.BasicService = services.NewBasicService(s.start, s.running, s.stop) return s, nil @@ -64,7 +74,7 @@ func (s *frontendService) newFrontendServer(ctx context.Context) *http.Server { router := http.NewServeMux() router.Handle("/metrics", promhttp.HandlerFor(s.promGatherer, promhttp.HandlerOpts{EnableOpenMetrics: true})) - router.HandleFunc("/", s.handleRequest) + router.HandleFunc("/", s.index.HandleRequest) server := &http.Server{ // 5s timeout for header reads to avoid Slowloris attacks (https://thetooth.io/blog/slowloris-attack/) @@ -76,34 +86,3 @@ func (s *frontendService) newFrontendServer(ctx context.Context) *http.Server { return server } - -func (s *frontendService) handleRequest(writer http.ResponseWriter, request *http.Request) { - // This should: - // - get correct asset urls from fs or cdn - // - generate a nonce - // - render them into the index.html - // - and return it to the user! - - s.log.Info("handling request", "method", request.Method, "url", request.URL.String()) - htmlContent := ` - - - Grafana Frontend Server - - - -

Grafana Frontend Server

-

This is a simple static HTML page served by the Grafana frontend server module.

- -` - - writer.Header().Set("Content-Type", "text/html; charset=utf-8") - _, err := writer.Write([]byte(htmlContent)) - if err != nil { - s.log.Error("could not write to response", "err", err) - } -} diff --git a/pkg/services/frontend/index.go b/pkg/services/frontend/index.go new file mode 100644 index 00000000000..4f02bbe1127 --- /dev/null +++ b/pkg/services/frontend/index.go @@ -0,0 +1,108 @@ +package frontend + +import ( + "context" + "embed" + "errors" + "fmt" + "html/template" + "net/http" + "syscall" + + "github.com/grafana/grafana-app-sdk/logging" + "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/api/webassets" + "github.com/grafana/grafana/pkg/middleware" + "github.com/grafana/grafana/pkg/services/licensing" + "github.com/grafana/grafana/pkg/setting" +) + +type IndexProvider struct { + log logging.Logger + index *template.Template + data IndexViewData +} + +type IndexViewData struct { + CSPContent string + CSPEnabled bool + IsDevelopmentEnv bool + + AppSubUrl string + BuildVersion string + BuildCommit string + AppTitle string + + Assets *dtos.EntryPointAssets // Includes CDN info + + // Nonce is a cryptographic identifier for use with Content Security Policy. + Nonce string +} + +// Templates setup. +var ( + //go:embed *.html + templatesFS embed.FS + + // templates + htmlTemplates = template.Must(template.New("html").Delims("[[", "]]").ParseFS(templatesFS, `*.html`)) +) + +func NewIndexProvider(cfg *setting.Cfg, license licensing.Licensing) (*IndexProvider, error) { + assets, err := webassets.GetWebAssets(context.Background(), cfg, license) + if err != nil { + return nil, err + } + t := htmlTemplates.Lookup("index.html") + if t == nil { + return nil, fmt.Errorf("missing index template") + } + + return &IndexProvider{ + log: logging.DefaultLogger.With("logger", "index-provider"), + index: t, + data: IndexViewData{ + AppTitle: "Grafana", + AppSubUrl: cfg.AppSubURL, // Based on the request? + BuildVersion: cfg.BuildVersion, + BuildCommit: cfg.BuildCommit, + Assets: assets, + + CSPEnabled: cfg.CSPEnabled, + CSPContent: cfg.CSPTemplate, + + IsDevelopmentEnv: cfg.Env == setting.Dev, + }, + }, nil +} + +func (p *IndexProvider) HandleRequest(writer http.ResponseWriter, request *http.Request) { + if request.Method != "GET" { + writer.WriteHeader(http.StatusMethodNotAllowed) + return + } + + nonce, err := middleware.GenerateNonce() + if err != nil { + p.log.Error("error creating nonce", "err", err) + writer.WriteHeader(500) + return + } + + // TODO -- restructure so the static stuff is under one variable and the rest is dynamic + data := p.data // copy everything + data.Nonce = nonce + + if data.CSPEnabled { + data.CSPContent = middleware.ReplacePolicyVariables(p.data.CSPContent, p.data.AppSubUrl, data.Nonce) + } + + writer.Header().Set("Content-Type", "text/html; charset=UTF-8") + writer.WriteHeader(200) + if err := p.index.Execute(writer, &data); err != nil { + if errors.Is(err, syscall.EPIPE) { // Client has stopped listening. + return + } + panic(fmt.Sprintf("Error rendering index\n %s", err.Error())) + } +} diff --git a/pkg/services/frontend/index.html b/pkg/services/frontend/index.html new file mode 100644 index 00000000000..6778c160ca7 --- /dev/null +++ b/pkg/services/frontend/index.html @@ -0,0 +1,33 @@ + + + + [[ if and .CSPEnabled .IsDevelopmentEnv ]] + + + [[ end ]] + + + + + + [[.AppTitle]] + + + + + + [[range $asset := .Assets.CSSFiles]] + + [[end]] + + + + + + +

Grafana Frontend Server ([[.BuildVersion]])

+

This is a simple static HTML page served by the Grafana frontend server module.

+ +