Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/workflows/golangci-lint.yml
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ version: "2"
run:
tests: false
linters:
settings:
revive:
rules:
- name: var-naming
disabled: true
default: all
disable:
- wsl_v5
Expand Down Expand Up @@ -29,6 +34,7 @@ linters:
- legacy
- std-error-handling
paths:
- internal/icinga/tls.go
- third_party$
- builtin$
- examples$
Expand Down
58 changes: 53 additions & 5 deletions icinga2_exporter.go
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -48,6 +52,7 @@ func main() {
cliUsername string
cliPassword string
cliBaseURL string
cliCacheTTL uint
cliVersion bool
cliDebugLog bool
cliInsecure bool
Expand All @@ -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")
Expand All @@ -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)
}

Expand All @@ -93,13 +99,19 @@ 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,
CAFile: cliCAFile,
CertFile: cliCertFile,
KeyFile: cliKeyFile,
Insecure: cliInsecure,
CacheTTL: cacheTTL,
IcingaAPIURI: *u,
}

Expand All @@ -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(`
<html>
Expand All @@ -128,7 +154,29 @@ func main() {
</html>`))
})

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())
}
}
59 changes: 59 additions & 0 deletions internal/collector/application.go
Original file line number Diff line number Diff line change
@@ -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)
}
47 changes: 47 additions & 0 deletions internal/icinga/cache.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading