diff --git a/bootstrap/bootstrap.go b/bootstrap/bootstrap.go new file mode 100644 index 00000000..199c2397 --- /dev/null +++ b/bootstrap/bootstrap.go @@ -0,0 +1,268 @@ +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package bootstrap provides a small bootstrap layer for exporters that want the +// common exporter-toolkit web flags, logging setup, landing page wiring, and +// listener startup behavior. + +package bootstrap + +import ( + "errors" + "log/slog" + "net/http" + "os" + + "github.com/alecthomas/kingpin/v2" + "github.com/prometheus/common/promslog" + promslogflag "github.com/prometheus/common/promslog/flag" + "github.com/prometheus/common/version" + "github.com/prometheus/exporter-toolkit/web" + "github.com/prometheus/exporter-toolkit/web/kingpinflag" +) + +var ( + // errNoMetricsHandler is returned when no metrics handler source was configured. + errNoMetricsHandler = errors.New("missing metrics handler") + // errMultipleMetricsSource is returned when both a static handler and a + // handler factory were configured. + errMultipleMetricsSource = errors.New("only one metrics handler source may be configured") + // errEmptyMetricsPath is returned when the configured metrics path is empty. + errEmptyMetricsPath = errors.New("metrics path must not be empty") + // errNegativeMaxRequests is returned when max requests is configured below zero. + errNegativeMaxRequests = errors.New("web max requests must be greater than or equal to zero") +) + +// MetricsHandlerFactory builds an exporter-specific metrics handler after the +// common toolkit flags have been parsed. +type MetricsHandlerFactory func(*Bootstrap) (http.Handler, error) + +// Bootstrap captures parsed startup options that handler factories can use to +// construct exporter-specific HTTP handlers after CLI parsing. +type Bootstrap struct { + // Logger is the configured logger for the exporter process. + Logger *slog.Logger + // MetricsPath is the parsed value of --web.telemetry-path. + MetricsPath string + // FlagConfig contains the parsed exporter-toolkit web flags. + FlagConfig *web.FlagConfig + // DisableExporterMetrics reports whether exporter self-metrics should be disabled. + DisableExporterMetrics bool + // MaxRequests is the parsed value of --web.max-requests. + MaxRequests int +} + +// Config defines the generic exporter bootstrap inputs. +type Config struct { + // App is the Kingpin application to register flags on. When nil, + // kingpin.CommandLine is used. + App *kingpin.Application + // Name is the exporter name used for version output and startup logging. + Name string + // Description is used as the default landing page description. + Description string + // DefaultAddress is the default value for --web.listen-address. + DefaultAddress string + // Logger is the logger to use. When nil, toolkit configures promslog flags + // and builds a logger during Parse. + Logger *slog.Logger + // LandingConfig customizes the generated landing page. + LandingConfig web.LandingConfig + // MetricsHandler is the static handler to register at the metrics path. + MetricsHandler http.Handler + // MetricsHandlerFactory builds the metrics handler after flags are parsed. + MetricsHandlerFactory MetricsHandlerFactory +} + +// Runner manages generic exporter startup around flag parsing, landing page +// setup, and web listener bootstrapping. +type Runner struct { + app *kingpin.Application + logConfig *promslog.Config + provided Config + + metricsPath *string + disableExporterMetrics *bool + maxRequests *int + + // Logger is the resolved logger after parsing configuration. + Logger *slog.Logger + // MetricsPath is the parsed value of --web.telemetry-path. + MetricsPath string + // FlagConfig contains the parsed exporter-toolkit web flags. + FlagConfig *web.FlagConfig + // DisableExporterMetrics is the parsed value of --web.disable-exporter-metrics. + DisableExporterMetrics bool + // MaxRequests is the parsed value of --web.max-requests. + MaxRequests int + // LandingConfig is the resolved landing page configuration. + LandingConfig web.LandingConfig + // MetricsHandler is the configured static metrics handler. + MetricsHandler http.Handler + // MetricsHandlerFactory is the configured deferred metrics handler builder. + MetricsHandlerFactory MetricsHandlerFactory +} + +// addFlags adds the common exporter web flags to a Kingpin application. +func addFlags(a *kingpin.Application, defaultAddress string) *web.FlagConfig { + return kingpinflag.AddFlags(a, defaultAddress) +} + +// New creates a generic exporter bootstrap instance. +func New(c Config) *Runner { + app := c.App + if app == nil { + app = kingpin.CommandLine + } + + t := &Runner{ + app: app, + provided: c, + Logger: c.Logger, + LandingConfig: c.LandingConfig, + MetricsHandler: c.MetricsHandler, + MetricsHandlerFactory: c.MetricsHandlerFactory, + FlagConfig: addFlags(app, c.DefaultAddress), + metricsPath: app.Flag( + "web.telemetry-path", + "Path under which to expose metrics.", + ).Default("/metrics").String(), + disableExporterMetrics: app.Flag( + "web.disable-exporter-metrics", + "Exclude metrics about the exporter itself (promhttp_*, process_*, go_*).", + ).Bool(), + maxRequests: app.Flag( + "web.max-requests", + "Maximum number of parallel scrape requests. Use 0 to disable.", + ).Default("40").Int(), + } + + if c.Logger == nil { + t.logConfig = &promslog.Config{} + promslogflag.AddFlags(app, t.logConfig) + } + + if c.Name != "" { + app.Version(version.Print(c.Name)) + } + app.HelpFlag.Short('h') + + return t +} + +// parse parses the provided arguments and resolves the derived bootstrap state. +func (t *Runner) parse(args []string) error { + if _, err := t.app.Parse(args); err != nil { + return err + } + if *t.metricsPath == "" { + return errEmptyMetricsPath + } + if *t.maxRequests < 0 { + return errNegativeMaxRequests + } + if t.Logger == nil { + t.Logger = promslog.New(t.logConfig) + } + t.MetricsPath = *t.metricsPath + t.DisableExporterMetrics = *t.disableExporterMetrics + t.MaxRequests = *t.maxRequests + t.LandingConfig = t.defaultLandingConfig() + return nil +} + +// Run parses os.Args and starts serving the configured exporter endpoints. +func (t *Runner) Run() error { + return t.runWithArgs(os.Args[1:]) +} + +// runWithArgs parses the provided args and starts the exporter HTTP server. +func (t *Runner) runWithArgs(args []string) error { + if err := t.parse(args); err != nil { + return err + } + handler, err := t.resolveMetricsHandler() + if err != nil { + return err + } + server, err := t.newServer(handler) + if err != nil { + return err + } + if t.provided.Name != "" { + t.Logger.Info("Starting "+t.provided.Name, "version", version.Info()) + t.Logger.Info("Build context", "build_context", version.BuildContext()) + } + return web.ListenAndServe(server, t.FlagConfig, t.Logger) +} + +func (t *Runner) resolveMetricsHandler() (http.Handler, error) { + sources := 0 + if t.MetricsHandler != nil { + sources++ + } + if t.MetricsHandlerFactory != nil { + sources++ + } + if sources == 0 { + return nil, errNoMetricsHandler + } + if sources > 1 { + return nil, errMultipleMetricsSource + } + if t.MetricsHandlerFactory != nil { + return t.MetricsHandlerFactory(&Bootstrap{ + Logger: t.Logger, + MetricsPath: t.MetricsPath, + FlagConfig: t.FlagConfig, + DisableExporterMetrics: t.DisableExporterMetrics, + MaxRequests: t.MaxRequests, + }) + } + return t.MetricsHandler, nil +} + +func (t *Runner) newServer(metricsHandler http.Handler) (*http.Server, error) { + mux := http.NewServeMux() + metricsPath := t.MetricsPath + mux.Handle(metricsPath, metricsHandler) + + if metricsPath != "/" { + landingConfig := t.LandingConfig + landingConfig.Links = append(landingConfig.Links, web.LandingLinks{ + Address: metricsPath, + Text: "Metrics", + }) + landingPage, err := web.NewLandingPage(landingConfig) + if err != nil { + return nil, err + } + mux.Handle("/", landingPage) + } + + return &http.Server{Handler: mux}, nil +} + +func (t *Runner) defaultLandingConfig() web.LandingConfig { + landingConfig := t.LandingConfig + if landingConfig.Name == "" { + landingConfig.Name = t.provided.Name + } + if landingConfig.Description == "" { + landingConfig.Description = t.provided.Description + } + if landingConfig.Version == "" { + landingConfig.Version = version.Info() + } + return landingConfig +} diff --git a/bootstrap/bootstrap_test.go b/bootstrap/bootstrap_test.go new file mode 100644 index 00000000..dfa43ec1 --- /dev/null +++ b/bootstrap/bootstrap_test.go @@ -0,0 +1,131 @@ +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bootstrap + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/alecthomas/kingpin/v2" + "github.com/prometheus/common/promslog" +) + +func TestParseRejectsNegativeMaxRequests(t *testing.T) { + tk := New(Config{ + App: kingpin.New("test", ""), + DefaultAddress: ":9100", + Logger: promslog.NewNopLogger(), + MetricsHandler: http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}), + }) + + err := tk.parse([]string{"--web.max-requests=-1", "--web.listen-address=:9100"}) + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestResolveMetricsHandlerRejectsMultipleSources(t *testing.T) { + tk := New(Config{ + App: kingpin.New("test", ""), + DefaultAddress: ":9100", + Logger: promslog.NewNopLogger(), + MetricsHandler: http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}), + MetricsHandlerFactory: func(*Bootstrap) (http.Handler, error) { + return http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}), nil + }, + }) + + if err := tk.parse([]string{"--web.listen-address=:9100"}); err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + if _, err := tk.resolveMetricsHandler(); err != errMultipleMetricsSource { + t.Fatalf("unexpected error: got %v, want %v", err, errMultipleMetricsSource) + } +} + +func TestResolveMetricsHandlerFactoryReceivesParsedBootstrap(t *testing.T) { + var got Bootstrap + tk := New(Config{ + App: kingpin.New("test", ""), + DefaultAddress: ":9100", + Logger: promslog.NewNopLogger(), + MetricsHandlerFactory: func(b *Bootstrap) (http.Handler, error) { + got = *b + return http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}), nil + }, + }) + + if err := tk.parse([]string{"--web.max-requests=7", "--web.disable-exporter-metrics", "--web.listen-address=:9100"}); err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + if _, err := tk.resolveMetricsHandler(); err != nil { + t.Fatalf("unexpected handler resolution error: %v", err) + } + if got.MaxRequests != 7 { + t.Fatalf("unexpected max requests: got %d, want 7", got.MaxRequests) + } + if !got.DisableExporterMetrics { + t.Fatal("expected disable exporter metrics to be true") + } + if got.MetricsPath != "/metrics" { + t.Fatalf("unexpected metrics path: got %q, want %q", got.MetricsPath, "/metrics") + } +} + +func TestNewServerRegistersMetricsAndLandingPage(t *testing.T) { + tk := New(Config{ + App: kingpin.New("test", ""), + Name: "test_exporter", + Description: "test description", + DefaultAddress: ":9100", + Logger: promslog.NewNopLogger(), + MetricsHandler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("metrics body")) + }), + }) + + if err := tk.parse([]string{"--web.listen-address=:9100"}); err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + handler, err := tk.resolveMetricsHandler() + if err != nil { + t.Fatalf("unexpected handler resolution error: %v", err) + } + server, err := tk.newServer(handler) + if err != nil { + t.Fatalf("unexpected server creation error: %v", err) + } + + metricsReq := httptest.NewRequest(http.MethodGet, "/metrics", nil) + metricsRec := httptest.NewRecorder() + server.Handler.ServeHTTP(metricsRec, metricsReq) + if metricsRec.Code != http.StatusOK { + t.Fatalf("unexpected metrics status: got %d, want %d", metricsRec.Code, http.StatusOK) + } + if body := metricsRec.Body.String(); body != "metrics body" { + t.Fatalf("unexpected metrics body: got %q", body) + } + + landingReq := httptest.NewRequest(http.MethodGet, "/", nil) + landingRec := httptest.NewRecorder() + server.Handler.ServeHTTP(landingRec, landingReq) + if landingRec.Code != http.StatusOK { + t.Fatalf("unexpected landing status: got %d, want %d", landingRec.Code, http.StatusOK) + } + if body := landingRec.Body.String(); body == "" || !strings.Contains(body, "Metrics") || !strings.Contains(body, "test description") { + t.Fatalf("unexpected landing body: %q", body) + } +} diff --git a/web/tls_config.go b/web/tls_config.go index 88a3bc48..7245f741 100644 --- a/web/tls_config.go +++ b/web/tls_config.go @@ -39,6 +39,7 @@ import ( var ( errNoTLSConfig = errors.New("TLS config is not present") + ErrMissingFlag = errors.New("missing required flag configuration") ErrNoListeners = errors.New("no web listen address or systemd socket flag specified") ) @@ -66,9 +67,27 @@ type TLSConfig struct { } type FlagConfig struct { + // WebListenAddresses contains the listen addresses for the HTTP server. WebListenAddresses *[]string - WebSystemdSocket *bool - WebConfigFile *string + // WebSystemdSocket enables systemd socket activation listeners. + WebSystemdSocket *bool + // WebConfigFile points to the TLS and authentication configuration file. + WebConfigFile *string +} + +// checkFlags validates that the flag configuration contains the required +// listener and web config fields needed by the web package. +func (c *FlagConfig) checkFlags() error { + if c == nil { + return ErrMissingFlag + } + if c.WebConfigFile == nil { + return ErrMissingFlag + } + if c.WebSystemdSocket == nil && (c.WebListenAddresses == nil || len(*c.WebListenAddresses) == 0) { + return ErrNoListeners + } + return nil } // SetDirectory joins any relative file paths with dir. @@ -290,8 +309,8 @@ func ServeMultiple(listeners []net.Listener, server *http.Server, flags *FlagCon // FlagConfig is true. // The FlagConfig is also passed on to ServeMultiple. func ListenAndServe(server *http.Server, flags *FlagConfig, logger *slog.Logger) error { - if flags.WebSystemdSocket == nil && (flags.WebListenAddresses == nil || len(*flags.WebListenAddresses) == 0) { - return ErrNoListeners + if err := flags.checkFlags(); err != nil { + return err } if flags.WebSystemdSocket != nil && *flags.WebSystemdSocket {