diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml new file mode 100644 index 0000000..0cd3824 --- /dev/null +++ b/.github/workflows/golangci-lint.yml @@ -0,0 +1,20 @@ +name: golangci-lint +on: + push: + tags: + - v* + branches: + - main + pull_request: + +jobs: + golangci: + name: lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: golangci-lint + uses: golangci/golangci-lint-action@v9 + with: + version: v2.9.0 diff --git a/.golangci.yml b/.golangci.yml index 7dfc5d3..3d9951f 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -2,6 +2,11 @@ version: "2" run: tests: false linters: + settings: + revive: + rules: + - name: var-naming + disabled: true default: all disable: - wsl_v5 @@ -29,6 +34,7 @@ linters: - legacy - std-error-handling paths: + - internal/icinga/tls.go - third_party$ - builtin$ - examples$ diff --git a/icinga2_exporter.go b/icinga2_exporter.go index f331f17..bbb9351 100644 --- a/icinga2_exporter.go +++ b/icinga2_exporter.go @@ -1,13 +1,17 @@ package main import ( + "context" + "errors" "flag" "fmt" - "log" "log/slog" "net/http" "net/url" "os" + "os/signal" + "syscall" + "time" "github.com/martialblog/icinga2-exporter/internal/collector" "github.com/martialblog/icinga2-exporter/internal/icinga" @@ -48,6 +52,7 @@ func main() { cliUsername string cliPassword string cliBaseURL string + cliCacheTTL uint cliVersion bool cliDebugLog bool cliInsecure bool @@ -56,6 +61,7 @@ func main() { flag.StringVar(&cliListenAddress, "web.listen-address", ":9665", "Address on which to expose metrics and web interface.") flag.StringVar(&cliMetricsPath, "web.metrics-path", "/metrics", "Path under which to expose metrics.") + flag.UintVar(&cliCacheTTL, "web.cache-ttl", 60, "Cache lifetime in seconds for the Icinga API responses") flag.StringVar(&cliBaseURL, "icinga.api", "https://localhost:5665/v1", "Path to the Icinga2 API") flag.StringVar(&cliUsername, "icinga.username", "", "Icinga2 API Username") @@ -73,7 +79,7 @@ func main() { flag.Parse() if cliVersion { - fmt.Printf("icinga-exporter version: %s\n", version) + fmt.Printf("icinga-exporter version: %s\n", buildVersion()) os.Exit(0) } @@ -93,6 +99,11 @@ func main() { Level: logLevel, })) + // In general, listen to gosec. But it this case, I don't think someone + // is going to overflow the uint TTL for the cache lifetime. + // nolint:gosec + cacheTTL := time.Duration(cliCacheTTL) * time.Second + config := icinga.Config{ BasicAuthUsername: cliUsername, BasicAuthPassword: cliPassword, @@ -100,6 +111,7 @@ func main() { CertFile: cliCertFile, KeyFile: cliKeyFile, Insecure: cliInsecure, + CacheTTL: cacheTTL, IcingaAPIURI: *u, } @@ -111,12 +123,26 @@ func main() { // Register Collectors prometheus.MustRegister(collector.NewIcinga2CIBCollector(c, logger)) + prometheus.MustRegister(collector.NewIcinga2ApplicationCollector(c, logger)) if cliCollectorApiListener { prometheus.MustRegister(collector.NewIcinga2APICollector(c, logger)) } + // Create a central context to propagate a shutdown + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + srv := &http.Server{ + Addr: cliListenAddress, + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 3 * time.Second, + WriteTimeout: 3 * time.Second, + IdleTimeout: 5 * time.Second, + } + http.Handle(cliMetricsPath, promhttp.Handler()) + //nolint:errcheck http.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { w.Write([]byte(` @@ -128,7 +154,29 @@ func main() { `)) }) - log.Printf("Version: %s", buildVersion()) - log.Printf("Listening on address: %s", cliListenAddress) - log.Fatal(http.ListenAndServe(cliListenAddress, nil)) + go func() { + slog.Info("Listening on address", "port", cliListenAddress, "version", version, "commit", commit) + // nolint:noinlineerr + if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { + slog.Error("HTTP server error", "error", err.Error()) + } + + slog.Info("Received Shutdown. Stopped serving new connections.") + }() + + // The signal channel will block until we registered signals are received. + // We will then use a context with a timeout to shutdown the application gracefully. + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + <-sigChan + + shutdownCtx, shutdownCancel := context.WithTimeout(ctx, 5*time.Second) + defer shutdownCancel() + + // We are using Shutdown() with a timeout to gracefully + // shut down the server without interrupting any active connections. + // nolint:noinlineerr + if err := srv.Shutdown(shutdownCtx); err != nil { + slog.Error("HTTP shutdown error", "error", err.Error()) + } } diff --git a/internal/collector/application.go b/internal/collector/application.go new file mode 100644 index 0000000..5737ae2 --- /dev/null +++ b/internal/collector/application.go @@ -0,0 +1,59 @@ +package collector + +import ( + "log/slog" + + "github.com/martialblog/icinga2-exporter/internal/icinga" + + "github.com/prometheus/client_golang/prometheus" +) + +type Icinga2ApplicationCollector struct { + icingaClient *icinga.Client + logger *slog.Logger + info *prometheus.GaugeVec +} + +func NewIcinga2ApplicationCollector(client *icinga.Client, logger *slog.Logger) *Icinga2ApplicationCollector { + return &Icinga2ApplicationCollector{ + icingaClient: client, + logger: logger, + info: prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Namespace: "icinga2", + Name: "version_info", + Help: "A metric with a constant '1' value labeled by version", + }, + []string{"version"}, + ), + } +} + +func (collector *Icinga2ApplicationCollector) Describe(ch chan<- *prometheus.Desc) { + collector.info.Describe(ch) +} + +func (collector *Icinga2ApplicationCollector) Collect(ch chan<- prometheus.Metric) { + result, err := collector.icingaClient.GetApplicationMetrics() + + if err != nil { + collector.logger.Error("Could not retrieve Application metrics", "error", err.Error()) + return + } + + if len(result.Results) < 1 { + collector.logger.Debug("No results for Application metrics") + return + } + + // TODO: Use a custom unmarshal to avoid this + r := result.Results[0] + + collector.info.Reset() + + collector.info.With(prometheus.Labels{ + "version": r.Status.IcingaApplication.App.Version, + }).Set(1) + + collector.info.Collect(ch) +} diff --git a/internal/icinga/cache.go b/internal/icinga/cache.go new file mode 100644 index 0000000..60d4fb5 --- /dev/null +++ b/internal/icinga/cache.go @@ -0,0 +1,47 @@ +package icinga + +import ( + "sync" + "time" +) + +type entry struct { + data []byte + fetchedAt time.Time +} + +type Cache struct { + m sync.Map + ttl time.Duration +} + +func NewCache(ttl time.Duration) *Cache { + return &Cache{ttl: ttl} +} + +func (c *Cache) Set(key string, b []byte) { + // We're not creating a copy here, data never is mutated + c.m.Store(key, &entry{ + data: b, + fetchedAt: time.Now(), + }) +} + +func (c *Cache) Get(key string) ([]byte, bool) { + if v, ok := c.m.Load(key); ok { + e, ok := v.(*entry) + + if !ok { + return nil, false + } + + if c.ttl > 0 && time.Since(e.fetchedAt) > c.ttl { + c.m.Delete(key) + return nil, false + } + // Again we're not creating a copy here, data never is mutated + return e.data, true + } + + return nil, false +} diff --git a/internal/icinga/client.go b/internal/icinga/client.go index 22b15d8..f910add 100644 --- a/internal/icinga/client.go +++ b/internal/icinga/client.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "io" "net" "net/http" "net/url" @@ -11,8 +12,27 @@ import ( ) const ( - endpointCIB = "/status/CIB" - endpointApiListener = "/status/ApiListener" + endpointApiListener = "/status/ApiListener" + endpointApplication = "/status/IcingaApplication" + endpointCIB = "/status/CIB" + endpointCheckerComponent = "/status/CheckerComponent" + endpointCompatLogger = "/status/CompatLogger" + endpointElasticsearchWriter = "/status/ElasticsearchWriter" + endpointExternalCommandListener = "/status/ExternalCommandListener" + endpointFileLogger = "/status/FileLogger" + endpointGelfWriter = "/status/GelfWriter" + endpointGraphiteWriter = "/status/GraphiteWriter" + endpointIcingaApplication = "/status/IcingaApplication" + endpointIdoMysqlConnection = "/status/IdoMysqlConnection" + endpointIdoPgsqlConnection = "/status/IdoPgsqlConnection" + endpointInfluxdb2Writer = "/status/Influxdb2Writer" + endpointInfluxdbWriter = "/status/InfluxdbWriter" + endpointJournaldLogger = "/status/JournaldLogger" + endpointLivestatusListener = "/status/LivestatusListener" + endpointNotificationComponent = "/status/NotificationComponent" + endpointOpenTsdbWriter = "/status/OpenTsdbWriter" + endpointPerfdataWriter = "/status/PerfdataWriter" + endpointSyslogLogger = "/status/SyslogLogger" ) type Config struct { @@ -22,12 +42,15 @@ type Config struct { CertFile string KeyFile string Insecure bool + CacheTTL time.Duration IcingaAPIURI url.URL } type Client struct { Client http.Client URL url.URL + cache *Cache + config Config } func NewClient(c Config) (*Client, error) { @@ -58,43 +81,66 @@ func NewClient(c Config) (*Client, error) { rt = newBasicAuthRoundTripper(c.BasicAuthUsername, c.BasicAuthPassword, rt) } + cache := NewCache(c.CacheTTL) + cli := &Client{ URL: c.IcingaAPIURI, Client: http.Client{ Transport: rt, }, + config: c, + cache: cache, } return cli, nil } func (icinga *Client) GetApiListenerMetrics() (APIResult, error) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() + var result APIResult - u := icinga.URL.JoinPath(endpointApiListener) + body, errBody := icinga.fetchJSON(endpointApiListener) - req, errReq := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if errBody != nil { + return result, fmt.Errorf("error fetching response: %w", errBody) + } - var result APIResult + errDecode := json.Unmarshal(body, &result) - if errReq != nil { - return result, fmt.Errorf("error creating request: %w", errReq) + if errDecode != nil { + return result, fmt.Errorf("error parsing response: %w", errDecode) } - resp, errDo := icinga.Client.Do(req) + return result, nil +} - if errDo != nil { - return result, fmt.Errorf("error performing request: %w", errDo) +func (icinga *Client) GetCIBMetrics() (CIBResult, error) { + var result CIBResult + + body, errBody := icinga.fetchJSON(endpointCIB) + + if errBody != nil { + return result, fmt.Errorf("error fetching response: %w", errBody) } - if resp.StatusCode != http.StatusOK { - return result, fmt.Errorf("request failed: %s", resp.Status) + errDecode := json.Unmarshal(body, &result) + + if errDecode != nil { + return result, fmt.Errorf("error parsing response: %w", errDecode) } - defer resp.Body.Close() + return result, nil +} + +func (icinga *Client) GetApplicationMetrics() (ApplicationResult, error) { + var result ApplicationResult + + body, errBody := icinga.fetchJSON(endpointApplication) + + if errBody != nil { + return result, fmt.Errorf("error fetching response: %w", errBody) + } - errDecode := json.NewDecoder(resp.Body).Decode(&result) + errDecode := json.Unmarshal(body, &result) if errDecode != nil { return result, fmt.Errorf("error parsing response: %w", errDecode) @@ -103,37 +149,42 @@ func (icinga *Client) GetApiListenerMetrics() (APIResult, error) { return result, nil } -func (icinga *Client) GetCIBMetrics() (CIBResult, error) { +func (icinga *Client) fetchJSON(endpoint string) ([]byte, error) { + // Lookup data in the cache we go out and bother the Icinga API + if elem, ok := icinga.cache.Get(endpoint); ok { + return elem, nil + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - u := icinga.URL.JoinPath(endpointCIB) + u := icinga.URL.JoinPath(endpoint) req, errReq := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) - var result CIBResult - if errReq != nil { - return result, fmt.Errorf("error creating request: %w", errReq) + return []byte{}, fmt.Errorf("error creating request: %w", errReq) } resp, errDo := icinga.Client.Do(req) if errDo != nil { - return result, fmt.Errorf("error performing request: %w", errDo) + return []byte{}, fmt.Errorf("error performing request: %w", errDo) } if resp.StatusCode != http.StatusOK { - return result, fmt.Errorf("request failed: %s", resp.Status) + return []byte{}, fmt.Errorf("request failed: %s", resp.Status) } defer resp.Body.Close() - errDecode := json.NewDecoder(resp.Body).Decode(&result) + data, errRead := io.ReadAll(resp.Body) - if errDecode != nil { - return result, fmt.Errorf("error parsing response: %w", errDecode) + if errRead != nil { + return []byte{}, fmt.Errorf("reading response failed: %w", errRead) } - return result, nil + icinga.cache.Set(endpoint, data) + + return data, nil } diff --git a/internal/icinga/model.go b/internal/icinga/model.go index 98ed5fc..9ecc302 100644 --- a/internal/icinga/model.go +++ b/internal/icinga/model.go @@ -19,3 +19,26 @@ type CIBResult struct { Status map[string]float64 `json:"status,omitempty"` } `json:"results"` } + +type ApplicationResult struct { + Results []struct { + Name string `json:"name"` + Status struct { + IcingaApplication IcingaApplication `json:"icingaapplication"` + } `json:"status"` + } `json:"results"` +} + +type IcingaApplication struct { + App App `json:"app"` +} + +type App struct { + EnableEventHandlers bool `json:"enable_event_handlers"` + EnableFlapping bool `json:"enable_flapping"` + EnableHostChecks bool `json:"enable_host_checks"` + EnableNotifications bool `json:"enable_notifications"` + EnablePerfdata bool `json:"enable_perfdata"` + EnableServiceChecks bool `json:"enable_service_checks"` + Version string `json:"version"` +}