FEMT: Add feature toggle and expose the service in regular grafana (#104428)

pull/104258/head^2
Ryan McKinley 4 weeks ago committed by GitHub
parent 29b3738bc8
commit 7b492d7e16
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 4
      packages/grafana-data/src/types/featureToggles.gen.ts
  2. 9
      pkg/api/api.go
  3. 4
      pkg/middleware/csp.go
  4. 22
      pkg/server/module_server.go
  5. 8
      pkg/services/featuremgmt/registry.go
  6. 1
      pkg/services/featuremgmt/toggles_gen.csv
  7. 4
      pkg/services/featuremgmt/toggles_gen.go
  8. 25
      pkg/services/featuremgmt/toggles_gen.json
  9. 49
      pkg/services/frontend/frontend_service.go
  10. 108
      pkg/services/frontend/index.go
  11. 33
      pkg/services/frontend/index.html

@ -1014,6 +1014,10 @@ export interface FeatureToggles {
*/
pluginsAutoUpdate?: boolean;
/**
* Register MT frontend
*/
multiTenantFrontend?: boolean;
/**
* Enables the alerting list view v2 preview toggle
*/
alertingListViewV2PreviewToggle?: boolean;

@ -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)

@ -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

@ -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)

@ -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",

@ -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

1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
228 logsPanelControls preview @grafana/observability-logs false false true
229 metricsFromProfiles experimental @grafana/observability-traces-and-profiling false false true
230 pluginsAutoUpdate experimental @grafana/plugins-platform-backend false false false
231 multiTenantFrontend experimental @grafana/grafana-frontend-platform false false false
232 alertingListViewV2PreviewToggle privatePreview @grafana/alerting-squad false false true
233 alertRuleUseFiredAtForStartsAt experimental @grafana/alerting-squad false false false
234 alertingBulkActionsInUI GA @grafana/alerting-squad false false true

@ -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"

@ -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",

@ -6,11 +6,13 @@ import (
"net/http"
"time"
"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"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
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 := `<!DOCTYPE html>
<html>
<head>
<title>Grafana Frontend Server</title>
<style>
body {
font-family: sans-serif;
}
</style>
</head>
<body>
<h1>Grafana Frontend Server</h1>
<p>This is a simple static HTML page served by the Grafana frontend server module.</p>
</body>
</html>`
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)
}
}

@ -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()))
}
}

@ -0,0 +1,33 @@
<!DOCTYPE html>
<html>
<head>
[[ if and .CSPEnabled .IsDevelopmentEnv ]]
<!-- Cypress overwrites CSP headers in HTTP requests, so this is required for e2e tests-->
<meta http-equiv="Content-Security-Policy" content="[[.CSPContent]]"/>
[[ end ]]
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
<meta name="viewport" content="width=device-width" />
<meta name="theme-color" content="#000" />
<title>[[.AppTitle]]</title>
<base href="[[.AppSubUrl]]/" />
<link rel="mask-icon" href="[[.Assets.ContentDeliveryURL]]public/img/grafana_mask_icon.svg" color="#F05A28" />
[[range $asset := .Assets.CSSFiles]]
<link rel="stylesheet" href="[[$asset.FilePath]]" />
[[end]]
<script nonce="[[.Nonce]]">
performance.mark('frontend_boot_css_time_seconds');
</script>
</head>
<body>
<h1>Grafana Frontend Server ([[.BuildVersion]])</h1>
<p>This is a simple static HTML page served by the Grafana frontend server module.</p>
</body>
</html>
Loading…
Cancel
Save