FS: Call IndexDataHooks for custom version string (#112670)

* Add enterprise hooks

* wip...

* undo

* update wire gen

* remove old hook thing

* move build info into seperate func

* align fs context middleware with grafana, setting SignedInUser

* Call IndexDataHooks to get modified build info

* update tests

* go workspace

* idk, reset workspace files or whatever

* conditionally mount license

* support loading decoupled plugins from cdn

---------

Co-authored-by: Ashley Harrison <ashley.harrison@grafana.com>
This commit is contained in:
Josh Hunt
2025-10-24 11:04:44 +01:00
committed by GitHub
co-authored by Ashley Harrison
parent 71d10a3fa3
commit bb6d7d02c7
11 changed files with 196 additions and 39 deletions
+9 -2
View File
@@ -44,7 +44,14 @@ local_resource(
)
# --- Docker Compose
docker_compose("./docker-compose.yaml")
# define service overrides needed for running with enterprise
# this mounts the dev license into the grafana-api service
base_config = read_yaml('./docker-compose.yaml')
base_volumes = base_config['services']['grafana-api']['volumes']
enterprise_overrides = {'services':{'grafana-api': {'volumes': base_volumes + ['../../data/license.jwt:/grafana/data/license.jwt'] }}}
# check if license exists and apply enterprise overrides if so
docker_compose(["./docker-compose.yaml", encode_yaml(enterprise_overrides)]) if os.path.exists("../../data/license.jwt") else docker_compose("./docker-compose.yaml")
dc_resource("proxy",
resource_deps=["grafana-api", "frontend-service"],
labels=["services"]
@@ -66,7 +73,7 @@ dc_resource("postgres", labels=["misc"])
dc_resource("tempo-init", labels=["misc"])
# paths in tilt files are confusing....
# - if tilt is dealing the the path, it is relative to the Tiltfile
# - if tilt is dealing with the path, it is relative to the Tiltfile
# - if docker is dealing with the path, it is relative to the context
docker_build('grafana-fs-dev',
# Set the docker context to the root of the repo
+13 -4
View File
@@ -1,9 +1,9 @@
#!/bin/bash
cd ../../
echo "Go mod cache: $(go env GOMODCACHE), $(ls -1 $(go env GOMODCACHE) | wc -l) items"
echo "Go build cache: $(go env GOCACHE), $(ls -1 $(go env GOCACHE) | wc -l) items"
# Support running this file from tilt (where the cwd is devenv/frontend-service), or directly from the root
if [[ -f build-grafana.sh ]]; then
cd ../../
fi
# The docker container, even on macOS, is linux, so we need to cross-compile
# on macOS hosts to work on linux.
@@ -17,7 +17,16 @@ fi
# Need to build version into the binary so plugin compatibility works correctly
VERSION=$(jq -r .version package.json)
# Build enterprise if it is linked in
EXTRA_TAGS=""
if [[ -f pkg/extensions/ext.go ]]; then
EXTRA_TAGS="-tags enterprise"
fi
# EXTRA_TAGS is intentionally unquoted to build the command
# shellcheck disable=SC2086
go build -v \
-ldflags "-X main.version=${VERSION}" \
-gcflags "all=-N -l" \
${EXTRA_TAGS} \
-o ./devenv/frontend-service/build/grafana ./pkg/cmd/grafana
@@ -8,6 +8,7 @@ services:
dockerfile: proxy.dockerfile
volumes:
- ../../public/build:/cdn/public/build
- ../../public/app/plugins:/cdn/public/app/plugins
- ../../public/fonts:/cdn/public/fonts
ports:
- '3000:80' # Gateway
+7 -2
View File
@@ -26,6 +26,7 @@ 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/hooks"
"github.com/grafana/grafana/pkg/services/licensing"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/storage/unified/resource"
@@ -46,8 +47,9 @@ func NewModule(opts Options,
license licensing.Licensing,
moduleRegisterer ModuleRegisterer,
storageBackend resource.StorageBackend, // Ensures unified storage backend is initialized
hooksService *hooks.HooksService,
) (*ModuleServer, error) {
s, err := newModuleServer(opts, apiOpts, features, cfg, storageMetrics, indexMetrics, reg, promGatherer, license, moduleRegisterer, storageBackend)
s, err := newModuleServer(opts, apiOpts, features, cfg, storageMetrics, indexMetrics, reg, promGatherer, license, moduleRegisterer, storageBackend, hooksService)
if err != nil {
return nil, err
}
@@ -70,6 +72,7 @@ func newModuleServer(opts Options,
license licensing.Licensing,
moduleRegisterer ModuleRegisterer,
storageBackend resource.StorageBackend,
hooksService *hooks.HooksService,
) (*ModuleServer, error) {
rootCtx, shutdownFn := context.WithCancel(context.Background())
@@ -93,6 +96,7 @@ func newModuleServer(opts Options,
license: license,
moduleRegisterer: moduleRegisterer,
storageBackend: storageBackend,
hooksService: hooksService,
}
return s, nil
@@ -134,6 +138,7 @@ type ModuleServer struct {
// moduleRegisterer allows registration of modules provided by other builds (e.g. enterprise).
moduleRegisterer ModuleRegisterer
hooksService *hooks.HooksService
}
// init initializes the server and its services.
@@ -205,7 +210,7 @@ func (s *ModuleServer) Run() error {
})
m.RegisterModule(modules.FrontendServer, func() (services.Service, error) {
return frontend.ProvideFrontendService(s.cfg, s.features, s.promGatherer, s.registerer, s.license)
return frontend.ProvideFrontendService(s.cfg, s.features, s.promGatherer, s.registerer, s.license, s.hooksService)
})
m.RegisterModule(modules.OperatorServer, s.initOperatorServer)
+5 -1
View File
@@ -27,6 +27,8 @@ import (
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/modules"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/hooks"
"github.com/grafana/grafana/pkg/services/licensing"
"github.com/grafana/grafana/pkg/services/sqlstore/sqlutil"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/storage/unified/resource"
@@ -330,8 +332,10 @@ func initModuleServerForTest(
apiOpts api.ServerOptions,
) testModuleServer {
tracer := tracing.InitializeTracerForTest()
hooksService := hooks.ProvideService()
license := &licensing.OSSLicensingService{}
ms, err := NewModule(opts, apiOpts, featuremgmt.WithFeatures(featuremgmt.FlagUnifiedStorageSearch), cfg, nil, nil, prometheus.NewRegistry(), prometheus.DefaultGatherer, tracer, nil, ProvideNoopModuleRegisterer(), nil)
ms, err := NewModule(opts, apiOpts, featuremgmt.WithFeatures(featuremgmt.FlagUnifiedStorageSearch), cfg, nil, nil, prometheus.NewRegistry(), prometheus.DefaultGatherer, tracer, license, ProvideNoopModuleRegisterer(), nil, hooksService)
require.NoError(t, err)
conn, err := grpc.NewClient(cfg.GRPCServer.Address,
+1 -1
View File
@@ -1644,7 +1644,7 @@ func InitializeModuleServer(cfg *setting.Cfg, opts Options, apiOpts api.ServerOp
if err != nil {
return nil, err
}
moduleServer, err := NewModule(opts, apiOpts, featureToggles, cfg, storageMetrics, bleveIndexMetrics, registerer, gatherer, tracingService, ossLicensingService, moduleRegisterer, storageBackend)
moduleServer, err := NewModule(opts, apiOpts, featureToggles, cfg, storageMetrics, bleveIndexMetrics, registerer, gatherer, tracingService, ossLicensingService, moduleRegisterer, storageBackend, hooksService)
if err != nil {
return nil, err
}
+33 -15
View File
@@ -6,8 +6,12 @@ import (
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/tracing"
"go.opentelemetry.io/otel/trace"
"github.com/grafana/grafana/pkg/services/contexthandler/ctxkey"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/web"
)
@@ -18,24 +22,38 @@ func (s *frontendService) contextMiddleware() web.Middleware {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
reqContext := &contextmodel.ReqContext{
Context: web.FromContext(ctx),
Logger: log.New("context"),
}
span := trace.SpanFromContext(ctx)
ctx = setRequestContext(ctx)
// inject ReqContext in the context
ctx = context.WithValue(ctx, ctxkey.Key{}, reqContext)
// Set the context for the http.Request.Context
// This modifies both r and reqContext.Req since they point to the same value
*reqContext.Req = *reqContext.Req.WithContext(ctx)
traceID := tracing.TraceIDFromContext(ctx, false)
if traceID != "" {
reqContext.Logger = reqContext.Logger.New("traceID", traceID)
}
// Preserve the original span so the setRequestContext span doesn't get propagated as a parent of the rest of the request
ctx = trace.ContextWithSpan(ctx, span)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func setRequestContext(ctx context.Context) context.Context {
ctx, span := tracing.Start(ctx, "setRequestContext")
defer span.End()
reqContext := &contextmodel.ReqContext{
Context: web.FromContext(ctx),
Logger: log.New("context"),
SignedInUser: &user.SignedInUser{},
}
// inject ReqContext in the context
ctx = context.WithValue(ctx, ctxkey.Key{}, reqContext)
// Set the context for the http.Request.Context
// This modifies both r and reqContext.Req since they point to the same value
*reqContext.Req = *reqContext.Req.WithContext(ctx)
traceID := tracing.TraceIDFromContext(ctx, false)
if traceID != "" {
reqContext.Logger = reqContext.Logger.New("traceID", traceID)
}
return ctx
}
+3 -2
View File
@@ -20,6 +20,7 @@ import (
"github.com/grafana/grafana/pkg/middleware/requestmeta"
"github.com/grafana/grafana/pkg/services/featuremgmt"
fswebassets "github.com/grafana/grafana/pkg/services/frontend/webassets"
"github.com/grafana/grafana/pkg/services/hooks"
"github.com/grafana/grafana/pkg/services/licensing"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/web"
@@ -50,13 +51,13 @@ type frontendService struct {
index *IndexProvider
}
func ProvideFrontendService(cfg *setting.Cfg, features featuremgmt.FeatureToggles, promGatherer prometheus.Gatherer, promRegister prometheus.Registerer, license licensing.Licensing) (*frontendService, error) {
func ProvideFrontendService(cfg *setting.Cfg, features featuremgmt.FeatureToggles, promGatherer prometheus.Gatherer, promRegister prometheus.Registerer, license licensing.Licensing, hooksService *hooks.HooksService) (*frontendService, error) {
assetsManifest, err := fswebassets.GetWebAssets(cfg, license)
if err != nil {
return nil, err
}
index, err := NewIndexProvider(cfg, assetsManifest)
index, err := NewIndexProvider(cfg, assetsManifest, license, hooksService)
if err != nil {
return nil, err
}
+58 -1
View File
@@ -11,8 +11,11 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/services/contexthandler"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/hooks"
"github.com/grafana/grafana/pkg/services/licensing"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/web"
@@ -24,6 +27,7 @@ func createTestService(t *testing.T, cfg *setting.Cfg) *frontendService {
features := featuremgmt.WithFeatures()
license := &licensing.OSSLicensingService{}
hooksService := hooks.ProvideService()
var promRegister prometheus.Registerer = prometheus.NewRegistry()
promGatherer := promRegister.(*prometheus.Registry)
@@ -32,7 +36,7 @@ func createTestService(t *testing.T, cfg *setting.Cfg) *frontendService {
cfg.BuildVersion = "10.3.0"
}
service, err := ProvideFrontendService(cfg, features, promGatherer, promRegister, license)
service, err := ProvideFrontendService(cfg, features, promGatherer, promRegister, license, hooksService)
require.NoError(t, err)
return service
@@ -182,3 +186,56 @@ func TestFrontendService_Middleware(t *testing.T) {
mux.ServeHTTP(recorder, req)
})
}
func TestFrontendService_IndexHooks(t *testing.T) {
publicDir := setupTestWebAssets(t)
cfg := &setting.Cfg{
HTTPPort: "3000",
StaticRootPath: publicDir,
BuildVersion: "10.3.0",
}
t.Run("should handle hooks that modify buildInfo fields", func(t *testing.T) {
service := createTestService(t, cfg)
// Add a hook that modifies various buildInfo fields
service.index.hooksService.AddIndexDataHook(func(indexData *dtos.IndexViewData, req *contextmodel.ReqContext) {
indexData.Settings.BuildInfo.Version = "99.99.99"
indexData.Settings.BuildInfo.VersionString = "Custom Edition v99.99.99 (custom)"
indexData.Settings.BuildInfo.Edition = "custom-edition"
})
mux := web.New()
service.addMiddlewares(mux)
service.registerRoutes(mux)
req := httptest.NewRequest("GET", "/", nil)
recorder := httptest.NewRecorder()
mux.ServeHTTP(recorder, req)
assert.Equal(t, 200, recorder.Code)
body := recorder.Body.String()
assert.Contains(t, body, "99.99.99", "Hook should have modified the version")
assert.Contains(t, body, "Custom Edition v99.99.99 (custom)", "Hook should have modified the version string")
assert.Contains(t, body, "custom-edition", "Hook should have modified the edition")
})
t.Run("should work without any hooks registered", func(t *testing.T) {
service := createTestService(t, cfg)
mux := web.New()
service.addMiddlewares(mux)
service.registerRoutes(mux)
req := httptest.NewRequest("GET", "/", nil)
recorder := httptest.NewRecorder()
mux.ServeHTTP(recorder, req)
assert.Equal(t, 200, recorder.Code)
body := recorder.Body.String()
assert.Contains(t, body, "<div id=\"reactRoot\"></div>")
// The build version comes from setting.BuildVersion (global), not cfg.BuildVersion
// So we just check that the page renders successfully
assert.Contains(t, body, "window.grafanaBootData")
})
}
+6 -1
View File
@@ -1,6 +1,9 @@
package frontend
import "github.com/grafana/grafana/pkg/setting"
import (
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/setting"
)
// This is a copy of dtos.FrontendSettingsDTO with only the fields that the frontend-service
// sends, to prevent default values from overriding what comes from the /bootdata call.
@@ -20,6 +23,8 @@ type FSFrontendSettings struct {
PasswordHint string `json:"passwordHint,omitempty"`
AnonymousEnabled bool `json:"anonymousEnabled,omitempty"`
BuildInfo dtos.FrontendSettingsBuildInfoDTO `json:"buildInfo"`
GoogleAnalyticsId string `json:"googleAnalyticsId,omitempty"`
GoogleAnalytics4Id string `json:"googleAnalytics4Id,omitempty"`
GoogleAnalytics4SendManualPageViews bool `json:"GoogleAnalytics4SendManualPageViews,omitempty"`
+60 -10
View File
@@ -12,13 +12,18 @@ import (
"github.com/grafana/grafana-app-sdk/logging"
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/middleware"
"github.com/grafana/grafana/pkg/services/contexthandler"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/hooks"
"github.com/grafana/grafana/pkg/services/licensing"
"github.com/grafana/grafana/pkg/setting"
)
type IndexProvider struct {
log logging.Logger
index *template.Template
data IndexViewData
log logging.Logger
index *template.Template
data IndexViewData
hooksService *hooks.HooksService
}
type IndexViewData struct {
@@ -51,12 +56,14 @@ var (
htmlTemplates = template.Must(template.New("html").Delims("[[", "]]").ParseFS(templatesFS, `*.html`))
)
func NewIndexProvider(cfg *setting.Cfg, assetsManifest dtos.EntryPointAssets) (*IndexProvider, error) {
func NewIndexProvider(cfg *setting.Cfg, assetsManifest dtos.EntryPointAssets, license licensing.Licensing, hooksService *hooks.HooksService) (*IndexProvider, error) {
t := htmlTemplates.Lookup("index.html")
if t == nil {
return nil, fmt.Errorf("missing index template")
}
logger := logging.DefaultLogger.With("logger", "index-provider")
// subset of frontend settings needed for the login page
// TODO what about enterprise settings here?
frontendSettings := FSFrontendSettings{
@@ -87,13 +94,13 @@ func NewIndexProvider(cfg *setting.Cfg, assetsManifest dtos.EntryPointAssets) (*
RudderstackWriteKey: cfg.RudderstackWriteKey,
TrustedTypesDefaultPolicyEnabled: (cfg.CSPEnabled && strings.Contains(cfg.CSPTemplate, "require-trusted-types-for")) || (cfg.CSPReportOnlyEnabled && strings.Contains(cfg.CSPReportOnlyTemplate, "require-trusted-types-for")),
VerifyEmailEnabled: cfg.VerifyEmailEnabled,
BuildInfo: getBuildInfo(license, cfg),
}
defaultUser := dtos.CurrentUser{}
return &IndexProvider{
log: logging.DefaultLogger.With("logger", "index-provider"),
index: t,
log: logger,
index: t,
hooksService: hooksService,
data: IndexViewData{
AppTitle: "Grafana",
AppSubUrl: cfg.AppSubURL, // Based on the request?
@@ -109,13 +116,13 @@ func NewIndexProvider(cfg *setting.Cfg, assetsManifest dtos.EntryPointAssets) (*
Assets: assetsManifest,
Settings: frontendSettings,
DefaultUser: defaultUser,
DefaultUser: dtos.CurrentUser{},
},
}, nil
}
func (p *IndexProvider) HandleRequest(writer http.ResponseWriter, request *http.Request) {
_, span := tracer.Start(request.Context(), "frontend.index.HandleRequest")
ctx, span := tracer.Start(request.Context(), "frontend.index.HandleRequest")
defer span.End()
if request.Method != "GET" {
@@ -142,6 +149,9 @@ func (p *IndexProvider) HandleRequest(writer http.ResponseWriter, request *http.
writer.Header().Set("Content-Security-Policy-Report-Only", policy)
}
reqCtx := contexthandler.FromContext(ctx)
p.runIndexDataHooks(reqCtx, &data)
writer.Header().Set("Content-Type", "text/html; charset=UTF-8")
writer.WriteHeader(200)
if err := p.index.Execute(writer, &data); err != nil {
@@ -151,3 +161,43 @@ func (p *IndexProvider) HandleRequest(writer http.ResponseWriter, request *http.
panic(fmt.Sprintf("Error rendering index\n %s", err.Error()))
}
}
func (p *IndexProvider) runIndexDataHooks(reqCtx *contextmodel.ReqContext, data *IndexViewData) {
// Create a dummy struct to pass to the hooks, and then extract the data back out from it
legacyIndexViewData := dtos.IndexViewData{
Settings: &dtos.FrontendSettingsDTO{
BuildInfo: data.Settings.BuildInfo,
},
}
p.hooksService.RunIndexDataHooks(&legacyIndexViewData, reqCtx)
data.Settings.BuildInfo = legacyIndexViewData.Settings.BuildInfo
}
func getBuildInfo(license licensing.Licensing, cfg *setting.Cfg) dtos.FrontendSettingsBuildInfoDTO {
version := setting.BuildVersion
commit := setting.BuildCommit
commitShort := getShortCommitHash(setting.BuildCommit, 10)
buildstamp := setting.BuildStamp
versionString := fmt.Sprintf(`%s v%s (%s)`, setting.ApplicationName, version, commitShort)
buildInfo := dtos.FrontendSettingsBuildInfoDTO{
Version: version,
VersionString: versionString,
Commit: commit,
CommitShort: commitShort,
Buildstamp: buildstamp,
Edition: license.Edition(),
Env: cfg.Env,
}
return buildInfo
}
func getShortCommitHash(commitHash string, maxLength int) string {
if len(commitHash) > maxLength {
return commitHash[:maxLength]
}
return commitHash
}