github.com/pyroscope-io/pyroscope@v0.37.3-0.20230725203016-5f6947968bd0/pkg/server/controller.go (about) 1 package server 2 3 import ( 4 "compress/gzip" 5 "context" 6 "errors" 7 "fmt" 8 golog "log" 9 "net/http" 10 "net/http/pprof" 11 "net/url" 12 "os" 13 "path/filepath" 14 "sync" 15 "sync/atomic" 16 "time" 17 18 "github.com/gorilla/handlers" 19 "github.com/gorilla/mux" 20 contentencoding "github.com/johejo/go-content-encoding" 21 "github.com/klauspost/compress/gzhttp" 22 "github.com/prometheus/client_golang/prometheus" 23 "github.com/prometheus/client_golang/prometheus/promhttp" 24 "github.com/pyroscope-io/pyroscope/pkg/history" 25 "github.com/pyroscope-io/pyroscope/pkg/ingestion" 26 "github.com/sirupsen/logrus" 27 metrics "github.com/slok/go-http-metrics/metrics/prometheus" 28 "github.com/slok/go-http-metrics/middleware" 29 "github.com/slok/go-http-metrics/middleware/std" 30 "gorm.io/gorm" 31 32 "github.com/pyroscope-io/pyroscope/pkg/api" 33 "github.com/pyroscope-io/pyroscope/pkg/api/authz" 34 "github.com/pyroscope-io/pyroscope/pkg/api/router" 35 "github.com/pyroscope-io/pyroscope/pkg/config" 36 "github.com/pyroscope-io/pyroscope/pkg/model" 37 "github.com/pyroscope-io/pyroscope/pkg/scrape" 38 "github.com/pyroscope-io/pyroscope/pkg/scrape/labels" 39 "github.com/pyroscope-io/pyroscope/pkg/server/httputils" 40 "github.com/pyroscope-io/pyroscope/pkg/service" 41 "github.com/pyroscope-io/pyroscope/pkg/storage" 42 "github.com/pyroscope-io/pyroscope/pkg/util/hyperloglog" 43 "github.com/pyroscope-io/pyroscope/pkg/util/updates" 44 "github.com/pyroscope-io/pyroscope/webapp" 45 ) 46 47 //revive:disable:max-public-structs TODO: we will refactor this later 48 49 const ( 50 stateCookieName = "pyroscopeState" 51 gzHTTPCompressionThreshold = 2000 52 ) 53 54 type Controller struct { 55 drained uint32 56 57 config *config.Server 58 storage *storage.Storage 59 ingestser ingestion.Ingester 60 log *logrus.Logger 61 httpServer *http.Server 62 db *gorm.DB 63 notifier Notifier 64 metricsMdw middleware.Middleware 65 dir http.FileSystem 66 67 httpUtils httputils.Utils 68 69 statsMutex sync.Mutex 70 stats map[string]int 71 72 appStats *hyperloglog.HyperLogLogPlus 73 74 // Exported metrics. 75 exportedMetrics *prometheus.Registry 76 77 // TODO: Should be moved to a separate Login handler/service. 78 authService service.AuthService 79 userService service.UserService 80 jwtTokenService service.JWTTokenService 81 apiKeyService service.APIKeyService 82 annotationsService service.AnnotationsService 83 signupDefaultRole model.Role 84 85 scrapeManager *scrape.Manager 86 historyMgr history.Manager 87 } 88 89 type Config struct { 90 Configuration *config.Server 91 Logger *logrus.Logger 92 // TODO(kolesnikovae): Ideally, Storage should be decomposed. 93 *storage.Storage 94 ingestion.Ingester 95 *gorm.DB 96 Notifier 97 98 // The registerer is used for exposing server metrics. 99 MetricsRegisterer prometheus.Registerer 100 ExportedMetricsRegistry *prometheus.Registry 101 ScrapeManager *scrape.Manager 102 HistoryMgr history.Manager 103 } 104 105 type StatsReceiver interface { 106 StatsInc(name string) 107 } 108 109 type Notifier interface { 110 // NotificationText returns message that will be displayed to user 111 // on index page load. The message should point user to a critical problem. 112 // TODO(kolesnikovae): we should poll for notifications (or subscribe). 113 NotificationText() string 114 } 115 116 type TargetsResponse struct { 117 Job string `json:"job"` 118 TargetURL string `json:"url"` 119 DiscoveredLabels labels.Labels `json:"discoveredLabels"` 120 Labels labels.Labels `json:"labels"` 121 Health scrape.TargetHealth `json:"health"` 122 LastScrape time.Time `json:"lastScrape"` 123 LastError string `json:"lastError"` 124 LastScrapeDuration string `json:"lastScrapeDuration"` 125 } 126 127 func New(c Config) (*Controller, error) { 128 if c.Configuration.BaseURL != "" { 129 _, err := url.Parse(c.Configuration.BaseURL) 130 if err != nil { 131 return nil, fmt.Errorf("BaseURL is invalid: %w", err) 132 } 133 } 134 135 if c.HistoryMgr == nil { 136 c.HistoryMgr = &history.NoopManager{} 137 } 138 139 ctrl := Controller{ 140 config: c.Configuration, 141 log: c.Logger, 142 storage: c.Storage, 143 ingestser: c.Ingester, 144 notifier: c.Notifier, 145 stats: make(map[string]int), 146 appStats: mustNewHLL(), 147 httpUtils: httputils.NewDefaultHelper(c.Logger), 148 149 exportedMetrics: c.ExportedMetricsRegistry, 150 metricsMdw: middleware.New(middleware.Config{ 151 Recorder: metrics.NewRecorder(metrics.Config{ 152 Prefix: "pyroscope", 153 Registry: c.MetricsRegisterer, 154 }), 155 }), 156 157 db: c.DB, 158 scrapeManager: c.ScrapeManager, 159 historyMgr: c.HistoryMgr, 160 } 161 162 var err error 163 if ctrl.dir, err = webapp.Assets(); err != nil { 164 return nil, err 165 } 166 if ctrl.signupDefaultRole, err = model.ParseRole(c.Configuration.Auth.SignupDefaultRole); err != nil { 167 return nil, fmt.Errorf("default signup role is invalid: %w", err) 168 } 169 170 return &ctrl, nil 171 } 172 173 func mustNewHLL() *hyperloglog.HyperLogLogPlus { 174 hll, err := hyperloglog.NewPlus(uint8(18)) 175 if err != nil { 176 panic(err) 177 } 178 return hll 179 } 180 181 func (ctrl *Controller) serverMux() (http.Handler, error) { 182 // TODO(kolesnikovae): 183 // - Move mux part to pkg/api/router. 184 // - Make prometheus middleware to support gorilla patterns. 185 // - Make diagnostic endpoints protection configurable. 186 // - Auth middleware should never redirect - the logic should be moved to the client side. 187 r := mux.NewRouter() 188 189 r.Use(contentencoding.Decode()) 190 191 ctrl.jwtTokenService = service.NewJWTTokenService( 192 []byte(ctrl.config.Auth.JWTSecret), 193 24*time.Hour*time.Duration(ctrl.config.Auth.LoginMaximumLifetimeDays)) 194 195 ctrl.apiKeyService = service.NewAPIKeyService(ctrl.db, ctrl.config.Auth.APIKeyBcryptCost) 196 ctrl.authService = service.NewAuthService(ctrl.db, ctrl.jwtTokenService, ctrl.apiKeyService) 197 ctrl.userService = service.NewUserService(ctrl.db) 198 ctrl.annotationsService = service.NewAnnotationsService(ctrl.db) 199 200 appMetadataSvc := service.NewApplicationMetadataService(ctrl.db) 201 appSvc := service.NewApplicationService(appMetadataSvc, ctrl.storage) 202 203 apiRouter := router.New(r.PathPrefix("/api").Subrouter(), router.Services{ 204 Logger: ctrl.log, 205 APIKeyService: ctrl.apiKeyService, 206 AuthService: ctrl.authService, 207 UserService: ctrl.userService, 208 AnnotationsService: ctrl.annotationsService, 209 AdhocService: service.NewAdhocService( 210 ctrl.config.MaxNodesRender, 211 ctrl.config.AdhocDataPath), 212 ApplicationListerAndDeleter: appSvc, 213 }) 214 215 apiRouter.Use( 216 ctrl.drainMiddleware, 217 ctrl.authMiddleware(nil)) 218 219 if ctrl.isAuthRequired() { 220 apiRouter.RegisterUserHandlers() 221 apiRouter.RegisterAPIKeyHandlers() 222 } 223 apiRouter.RegisterAnnotationsHandlers() 224 if !ctrl.config.NoAdhocUI { 225 apiRouter.RegisterAdhocHandlers(int64(ctrl.config.AdhocMaxFileSize)) 226 } 227 228 // FIXME: not optimal, unify this with the remoteReadHandler at the top 229 appsRouter := apiRouter.PathPrefix("/apps").Subrouter() 230 if ctrl.config.RemoteRead.Enabled { 231 h, err := ctrl.remoteReadHandler(ctrl.config.RemoteRead) 232 if err != nil { 233 logrus.WithError(err).Error("failed to initialize remote read handler") 234 } else { 235 appsRouter.Methods(http.MethodGet).Handler(h) 236 appsRouter.Methods(http.MethodDelete).Handler(h) 237 } 238 } else { 239 apiRouter.RegisterApplicationHandlers() 240 } 241 242 ingestRouter := r.Path("/ingest").Subrouter() 243 ingestRouter.Use(ctrl.drainMiddleware) 244 if ctrl.config.Auth.Ingestion.Enabled { 245 ingestRouter.Use( 246 ctrl.ingestionAuthMiddleware(), 247 authz.NewAuthorizer(ctrl.log, httputils.NewDefaultHelper(ctrl.log)).RequireOneOf( 248 authz.Role(model.AdminRole), 249 authz.Role(model.AgentRole), 250 )) 251 } 252 253 ingestRouter.Methods(http.MethodPost).Handler(ctrl.ingestHandler()) 254 255 // Routes not protected with auth. Drained at shutdown. 256 insecureRoutes, err := ctrl.getAuthRoutes() 257 if err != nil { 258 return nil, err 259 } 260 261 assetsHandler := r.PathPrefix("/assets/").Handler(http.FileServer(ctrl.dir)).GetHandler().ServeHTTP 262 ctrl.addRoutes(r, append(insecureRoutes, []route{ 263 {"/assets/", assetsHandler}}...), 264 ctrl.drainMiddleware) 265 266 // Protected pages: 267 // For these routes server responds with 307 and redirects to /login. 268 ih := ctrl.indexHandler() 269 ctrl.addRoutes(r, []route{ 270 {"/", ih}, 271 {"/comparison", ih}, 272 {"/comparison-diff", ih}, 273 {"/tracing", ih}, 274 {"/service-discovery", ih}, 275 {"/adhoc-single", ih}, 276 {"/adhoc-comparison", ih}, 277 {"/adhoc-comparison-diff", ih}, 278 {"/settings", ih}, 279 {"/settings/{page}", ih}, 280 {"/settings/{page}/{subpage}", ih}, 281 {"/exemplars/single", ih}, 282 {"/exemplars/merge", ih}, 283 {"/explore", ih}}, 284 ctrl.drainMiddleware, 285 ctrl.authMiddleware(ctrl.indexHandler())) 286 287 var routes []route 288 // TODO(kolesnikovae): Consider implementing a middleware. 289 if ctrl.config.RemoteRead.Enabled { 290 h, err := ctrl.remoteReadHandler(ctrl.config.RemoteRead) 291 if err != nil { 292 logrus.WithError(err).Error("failed to initialize remote read handler") 293 } else { 294 routes = append(routes, []route{ 295 {"/render", h}, 296 {"/render-diff", h}, 297 {"/labels", h}, 298 {"/label-values", h}, 299 {"/export", h}, 300 {"/merge", h}, 301 {"/api/exemplars:merge", h}, 302 {"/api/exemplars:query", h}, 303 // TODO(kolesnikovae): Add adhoc endpoints 304 }...) 305 } 306 } else { 307 routes = append(routes, []route{ 308 {"/render", ctrl.renderHandler()}, 309 {"/render-diff", ctrl.renderDiffHandler()}, 310 {"/labels", ctrl.labelsHandler()}, 311 {"/label-values", ctrl.labelValuesHandler()}, 312 {"/export", ctrl.exportHandler()}, 313 {"/merge", ctrl.exemplarsHandler().MergeExemplars}, 314 {"/api/exemplars:merge", ctrl.exemplarsHandler().MergeExemplars}, 315 {"/api/exemplars:query", ctrl.exemplarsHandler().QueryExemplars}, 316 }...) 317 } 318 319 // For these routes server responds with 401. 320 ctrl.addRoutes(r, routes, 321 ctrl.drainMiddleware, 322 ctrl.authMiddleware(nil)) 323 324 // TODO(kolesnikovae): 325 // Refactor: move mux part to pkg/api/router. 326 // Make prometheus middleware to support gorilla patterns. 327 328 // TODO(kolesnikovae): 329 // Make diagnostic endpoints protection configurable. 330 331 // Diagnostic secure routes: must be protected but not drained. 332 diagnosticSecureRoutes := []route{ 333 {"/config", ctrl.configHandler}, 334 {"/build", ctrl.buildHandler}, 335 {"/targets", ctrl.activeTargetsHandler}, 336 {"/debug/storage/export/{db}", ctrl.storage.DebugExport}, 337 } 338 if !ctrl.config.DisablePprofEndpoint { 339 diagnosticSecureRoutes = append(diagnosticSecureRoutes, []route{ 340 {"/debug/pprof/", pprof.Index}, 341 {"/debug/pprof/cmdline", pprof.Cmdline}, 342 {"/debug/pprof/profile", pprof.Profile}, 343 {"/debug/pprof/symbol", pprof.Symbol}, 344 {"/debug/pprof/trace", pprof.Trace}, 345 {"/debug/pprof/allocs", pprof.Index}, 346 {"/debug/pprof/goroutine", pprof.Index}, 347 {"/debug/pprof/heap", pprof.Index}, 348 {"/debug/pprof/threadcreate", pprof.Index}, 349 {"/debug/pprof/block", pprof.Index}, 350 {"/debug/pprof/mutex", pprof.Index}, 351 }...) 352 } 353 354 ctrl.addRoutes(r, diagnosticSecureRoutes, ctrl.authMiddleware(nil)) 355 ctrl.addRoutes(r, []route{ 356 {"/metrics", promhttp.Handler().ServeHTTP}, 357 {"/exported-metrics", ctrl.exportedMetricsHandler}, 358 {"/healthz", ctrl.healthz}, 359 }) 360 361 // Respond with 404 for all other routes. 362 r.NotFoundHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 363 w.WriteHeader(http.StatusNotFound) 364 ih(w, r) 365 }) 366 367 return r, nil 368 } 369 370 func (ctrl *Controller) activeTargetsHandler(w http.ResponseWriter, r *http.Request) { 371 targets := ctrl.scrapeManager.TargetsActive() 372 resp := []TargetsResponse{} 373 for k, v := range targets { 374 for _, t := range v { 375 var lastError string 376 if t.LastError() != nil { 377 lastError = t.LastError().Error() 378 } 379 resp = append(resp, TargetsResponse{ 380 Job: k, 381 TargetURL: t.URL().String(), 382 DiscoveredLabels: t.DiscoveredLabels(), 383 Labels: t.Labels(), 384 Health: t.Health(), 385 LastScrape: t.LastScrape(), 386 LastError: lastError, 387 LastScrapeDuration: t.LastScrapeDuration().String(), 388 }) 389 } 390 } 391 ctrl.httpUtils.WriteResponseJSON(r, w, resp) 392 } 393 394 func (ctrl *Controller) exportedMetricsHandler(w http.ResponseWriter, r *http.Request) { 395 promhttp.InstrumentMetricHandler(ctrl.exportedMetrics, 396 promhttp.HandlerFor(ctrl.exportedMetrics, promhttp.HandlerOpts{})). 397 ServeHTTP(w, r) 398 } 399 400 func (ctrl *Controller) getAuthRoutes() ([]route, error) { 401 authRoutes := []route{ 402 {"/login", ctrl.loginHandler}, 403 {"/logout", ctrl.logoutHandler}, 404 {"/signup", ctrl.signupHandler}, 405 } 406 407 if ctrl.config.Auth.Google.Enabled { 408 googleHandler, err := newOauthGoogleHandler(ctrl.config.Auth.Google, ctrl.config.BaseURL, ctrl.log) 409 if err != nil { 410 return nil, err 411 } 412 413 authRoutes = append(authRoutes, []route{ 414 {"/auth/google/login", ctrl.oauthLoginHandler(googleHandler)}, 415 {"/auth/google/callback", ctrl.callbackHandler(googleHandler.redirectRoute)}, 416 {"/auth/google/redirect", ctrl.callbackRedirectHandler(googleHandler)}, 417 }...) 418 } 419 420 if ctrl.config.Auth.Github.Enabled { 421 githubHandler, err := newGithubHandler(ctrl.config.Auth.Github, ctrl.config.BaseURL, ctrl.log) 422 if err != nil { 423 return nil, err 424 } 425 426 authRoutes = append(authRoutes, []route{ 427 {"/auth/github/login", ctrl.oauthLoginHandler(githubHandler)}, 428 {"/auth/github/callback", ctrl.callbackHandler(githubHandler.redirectRoute)}, 429 {"/auth/github/redirect", ctrl.callbackRedirectHandler(githubHandler)}, 430 }...) 431 } 432 433 if ctrl.config.Auth.Gitlab.Enabled { 434 gitlabHandler, err := newOauthGitlabHandler(ctrl.config.Auth.Gitlab, ctrl.config.BaseURL, ctrl.log) 435 if err != nil { 436 return nil, err 437 } 438 439 authRoutes = append(authRoutes, []route{ 440 {"/auth/gitlab/login", ctrl.oauthLoginHandler(gitlabHandler)}, 441 {"/auth/gitlab/callback", ctrl.callbackHandler(gitlabHandler.redirectRoute)}, 442 {"/auth/gitlab/redirect", ctrl.callbackRedirectHandler(gitlabHandler)}, 443 }...) 444 } 445 446 return authRoutes, nil 447 } 448 449 func (ctrl *Controller) getHandler() (http.Handler, error) { 450 handler, err := ctrl.serverMux() 451 if err != nil { 452 return nil, err 453 } 454 455 gzhttpMiddleware, err := gzhttp.NewWrapper(gzhttp.MinSize(gzHTTPCompressionThreshold), gzhttp.CompressionLevel(gzip.BestSpeed)) 456 if err != nil { 457 return nil, err 458 } 459 460 h := ctrl.corsMiddleware()(gzhttpMiddleware(handler)) 461 h = ctrl.logginMiddleware(h) 462 463 return h, nil 464 } 465 466 func (ctrl *Controller) Start() error { 467 return ctrl.startSync(nil) 468 } 469 470 func (ctrl *Controller) startSync(serveSync chan struct{}) error { 471 logger := logrus.New() 472 w := logger.Writer() 473 defer w.Close() 474 handler, err := ctrl.getHandler() 475 if err != nil { 476 return err 477 } 478 479 ctrl.httpServer = &http.Server{ 480 Addr: ctrl.config.APIBindAddr, 481 Handler: handler, 482 ReadTimeout: 10 * time.Second, 483 WriteTimeout: 15 * time.Second, 484 IdleTimeout: 30 * time.Second, 485 MaxHeaderBytes: 1 << 20, 486 ErrorLog: golog.New(w, "", 0), 487 } 488 489 updates.StartVersionUpdateLoop() 490 491 if serveSync != nil { 492 serveSync <- struct{}{} 493 } 494 495 if ctrl.config.TLSCertificateFile != "" && ctrl.config.TLSKeyFile != "" { 496 err = ctrl.httpServer.ListenAndServeTLS(ctrl.config.TLSCertificateFile, ctrl.config.TLSKeyFile) 497 } else { 498 err = ctrl.httpServer.ListenAndServe() 499 } 500 501 // ListenAndServe always returns a non-nil error. After Shutdown or Close, 502 // the returned error is ErrServerClosed. 503 if errors.Is(err, http.ErrServerClosed) { 504 return nil 505 } 506 return err 507 } 508 509 func (ctrl *Controller) Stop() error { 510 ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) 511 defer cancel() 512 return ctrl.httpServer.Shutdown(ctx) 513 } 514 515 func (ctrl *Controller) corsMiddleware() mux.MiddlewareFunc { 516 if len(ctrl.config.CORS.AllowedOrigins) > 0 { 517 options := []handlers.CORSOption{ 518 handlers.AllowedOrigins(ctrl.config.CORS.AllowedOrigins), 519 handlers.AllowedMethods(ctrl.config.CORS.AllowedMethods), 520 handlers.AllowedHeaders(ctrl.config.CORS.AllowedHeaders), 521 handlers.MaxAge(ctrl.config.CORS.MaxAge), 522 } 523 if ctrl.config.CORS.AllowCredentials { 524 options = append(options, handlers.AllowCredentials()) 525 } 526 return handlers.CORS(options...) 527 } 528 return func(next http.Handler) http.Handler { 529 return next 530 } 531 } 532 533 func (ctrl *Controller) Drain() { 534 atomic.StoreUint32(&ctrl.drained, 1) 535 } 536 537 func (ctrl *Controller) drainMiddleware(next http.Handler) http.Handler { 538 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 539 if atomic.LoadUint32(&ctrl.drained) > 0 { 540 w.WriteHeader(http.StatusServiceUnavailable) 541 return 542 } 543 next.ServeHTTP(w, r) 544 }) 545 } 546 547 func (ctrl *Controller) trackMetrics(route string) func(next http.Handler) http.Handler { 548 return func(next http.Handler) http.Handler { 549 return std.Handler(route, ctrl.metricsMdw, next) 550 } 551 } 552 553 func (ctrl *Controller) redirectPreservingBaseURL(w http.ResponseWriter, r *http.Request, urlStr string, status int) { 554 if ctrl.config.BaseURL != "" { 555 // we're modifying the URL here so I'm not memoizing it and instead parsing it all over again to create a new object 556 u, err := url.Parse(ctrl.config.BaseURL) 557 if err != nil { 558 // TODO: technically this should never happen because NewController would return an error 559 logrus.Error("base URL is invalid, some redirects might not work as expected") 560 } else { 561 urlStr = filepath.Join(u.Path, urlStr) 562 } 563 } 564 565 http.Redirect(w, r, urlStr, status) 566 } 567 568 func (ctrl *Controller) loginRedirect(w http.ResponseWriter, r *http.Request) { 569 ctrl.redirectPreservingBaseURL(w, r, "/login", http.StatusTemporaryRedirect) 570 } 571 572 func (ctrl *Controller) authMiddleware(redirect http.HandlerFunc) mux.MiddlewareFunc { 573 if ctrl.isAuthRequired() { 574 return api.AuthMiddleware(redirect, ctrl.authService, ctrl.httpUtils) 575 } 576 return func(next http.Handler) http.Handler { 577 return next 578 } 579 } 580 581 func (ctrl *Controller) ingestionAuthMiddleware() mux.MiddlewareFunc { 582 if ctrl.config.Auth.Ingestion.Enabled { 583 asConfig := service.CachingAuthServiceConfig{ 584 Size: ctrl.config.Auth.Ingestion.CacheSize, 585 TTL: ctrl.config.Auth.Ingestion.CacheTTL, 586 } 587 as := service.NewCachingAuthService(ctrl.authService, asConfig) 588 return api.AuthMiddleware(nil, as, ctrl.httpUtils) 589 } 590 return func(next http.Handler) http.Handler { 591 return next 592 } 593 } 594 595 func expectFormats(format string) error { 596 switch format { 597 case "json", "pprof", "collapsed", "html", "": 598 return nil 599 default: 600 return errUnknownFormat 601 } 602 } 603 604 func (ctrl *Controller) logginMiddleware(next http.Handler) http.Handler { 605 if ctrl.config.LogLevel == "debug" { 606 // log to Stdout using Apache Common Log Format 607 // TODO maybe use JSON? 608 return handlers.LoggingHandler(os.Stdout, next) 609 } 610 611 return next 612 }