diff --git a/devenv/frontend-service/Tiltfile b/devenv/frontend-service/Tiltfile index 194ea920269..9c443cf2ece 100644 --- a/devenv/frontend-service/Tiltfile +++ b/devenv/frontend-service/Tiltfile @@ -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 diff --git a/devenv/frontend-service/build-grafana.sh b/devenv/frontend-service/build-grafana.sh index bfa78c71d17..9f1011e259e 100755 --- a/devenv/frontend-service/build-grafana.sh +++ b/devenv/frontend-service/build-grafana.sh @@ -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 diff --git a/devenv/frontend-service/docker-compose.yaml b/devenv/frontend-service/docker-compose.yaml index fd744e29ccd..7eb625eaae2 100644 --- a/devenv/frontend-service/docker-compose.yaml +++ b/devenv/frontend-service/docker-compose.yaml @@ -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 diff --git a/pkg/server/module_server.go b/pkg/server/module_server.go index d40eec8b965..3954b29dac9 100644 --- a/pkg/server/module_server.go +++ b/pkg/server/module_server.go @@ -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) diff --git a/pkg/server/search_server_distributor_test.go b/pkg/server/search_server_distributor_test.go index 83c32fced37..d9e2f5e5048 100644 --- a/pkg/server/search_server_distributor_test.go +++ b/pkg/server/search_server_distributor_test.go @@ -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, diff --git a/pkg/server/wire_gen.go b/pkg/server/wire_gen.go index 1bce49fa21d..0bdaa1a7c6a 100644 --- a/pkg/server/wire_gen.go +++ b/pkg/server/wire_gen.go @@ -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 } diff --git a/pkg/services/frontend/context_middleware.go b/pkg/services/frontend/context_middleware.go index 5ef04ee95df..02fb3ed22fc 100644 --- a/pkg/services/frontend/context_middleware.go +++ b/pkg/services/frontend/context_middleware.go @@ -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 +} diff --git a/pkg/services/frontend/frontend_service.go b/pkg/services/frontend/frontend_service.go index 34acee7b0c7..d1ef49cabe0 100644 --- a/pkg/services/frontend/frontend_service.go +++ b/pkg/services/frontend/frontend_service.go @@ -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 } diff --git a/pkg/services/frontend/frontend_service_test.go b/pkg/services/frontend/frontend_service_test.go index dc39993a6a0..ff60cb73c03 100644 --- a/pkg/services/frontend/frontend_service_test.go +++ b/pkg/services/frontend/frontend_service_test.go @@ -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, "
") + // 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") + }) +} diff --git a/pkg/services/frontend/frontend_settings.go b/pkg/services/frontend/frontend_settings.go index 56e94bd12b8..9ffdb3dbad0 100644 --- a/pkg/services/frontend/frontend_settings.go +++ b/pkg/services/frontend/frontend_settings.go @@ -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"` diff --git a/pkg/services/frontend/index.go b/pkg/services/frontend/index.go index a2838fbd55b..89f7fcaf066 100644 --- a/pkg/services/frontend/index.go +++ b/pkg/services/frontend/index.go @@ -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 +}