From ebedca0f829ca85e682b20058c4625147c804d81 Mon Sep 17 00:00:00 2001 From: Gordon Bleux <33967640+UiP9AV6Y@users.noreply.github.com> Date: Sun, 11 Oct 2026 22:36:27 +0200 Subject: [PATCH] refactor Icinga client to use builder pattern using the builder pattern allows configuring individual aspects of the client. this change adds an alternative client constructor function while remaining backwards compatible. the only actual change in behaviour, is the `User-Agent` header sent to remote endpoints now contains the application name, version, and commit, instead of just the application name. future changes could add more control options for the client via the configuration. --- internal/icinga2/icinga.go | 79 ++++++++----- internal/icinga2/options.go | 215 ++++++++++++++++++++++++++++++++++++ main.go | 9 +- 3 files changed, 274 insertions(+), 29 deletions(-) create mode 100644 internal/icinga2/options.go diff --git a/internal/icinga2/icinga.go b/internal/icinga2/icinga.go index 8d95914..cdb78c4 100644 --- a/internal/icinga2/icinga.go +++ b/internal/icinga2/icinga.go @@ -34,46 +34,75 @@ var ( // Client is what we use to talk to the Icinga2 API type Client struct { IcingaURL []string - Username string - Password string + headers map[string]string httpClient *http.Client logger *slog.Logger - config *config.Config } func NewClient(config *config.Config, logger *slog.Logger) *Client { - var rt http.RoundTripper = &http.Transport{ - Proxy: http.ProxyFromEnvironment, - DialContext: (&net.Dialer{ - Timeout: 30 * time.Second, - KeepAlive: 30 * time.Second, - }).DialContext, - DisableKeepAlives: config.IcingaDisableKeepAlives, + return NewClientWithOptions( + WithIcingaBasicAuth(config.IcingaUser, config.IcingaPassword), + WithIcingaAPIEndpoints(config.IcingaURL), + WithIcingaLogger(logger), + WithTransportDisableKeepAlives(config.IcingaDisableKeepAlives), + WithTransportTLSClientConfig(config.IcingaTLSConfig), + ) +} + +// NewClientWithOptions returns a [Client] instance using the builder pattern. +// the provided options affect various internal components of the result. +func NewClientWithOptions(opts ...builderOption) *Client { + dialer := &net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + } + transport := &http.Transport{ + Proxy: http.ProxyFromEnvironment, ForceAttemptHTTP2: true, TLSHandshakeTimeout: 30 * time.Second, - TLSClientConfig: config.IcingaTLSConfig, } - client := &http.Client{ // We could also use a context.WithTimeout for the requests - Timeout: 10 * time.Second, - Transport: rt, + Timeout: 10 * time.Second, + } + headers := map[string]string{ + HeaderAccept: MIMETypeApplicationJSON, + HeaderContentType: MIMETypeApplicationJSON, + } + icinga := &Client{ + IcingaURL: []string{}, + headers: headers, + logger: slog.Default(), + } + + for _, o := range opts { + switch f := o.(type) { + case clientOption: + f.ApplyClient(client) + case dialerOption: + f.ApplyDialer(dialer) + case icingaOption: + f.ApplyIcinga(icinga) + case transportOption: + f.ApplyTransport(transport) + } } - return &Client{ - IcingaURL: config.IcingaURL, - Username: config.IcingaUser, - Password: config.IcingaPassword, - httpClient: client, - config: config, - logger: logger, - } + transport.DialContext = dialer.DialContext + client.Transport = transport + icinga.httpClient = client + + return icinga } // Do is a small wrapper that tries the given request against all given Icinga API endpoints func (c *Client) Do(req *http.Request, path string) (*http.Response, error) { var endpointErrors strings.Builder + for k, v := range c.headers { + req.Header.Set(k, v) + } + for _, base := range c.IcingaURL { // Since we determine what the path ist, this probably won't fail req.URL, _ = req.URL.Parse(base + path) @@ -86,12 +115,6 @@ func (c *Client) Do(req *http.Request, path string) (*http.Response, error) { c.logger.Debug(fmt.Sprintf("Calling Icinga API at %s", req.URL), "component", "icinga") - req.Header.Set("User-Agent", "alertmanager-icinga-bridge") - req.Header.Set("Accept", "application/json") - req.Header.Set("Content-Type", "application/json") - - req.SetBasicAuth(c.Username, c.Password) - resp, err := c.httpClient.Do(req) if err != nil { diff --git a/internal/icinga2/options.go b/internal/icinga2/options.go new file mode 100644 index 0000000..c38d68b --- /dev/null +++ b/internal/icinga2/options.go @@ -0,0 +1,215 @@ +//nolint:revive,ireturn +package icinga2 + +import ( + "crypto/tls" + "encoding/base64" + "log/slog" + "net" + "net/http" + "time" +) + +const ( + scopeClient = iota + scopeDialer + scopeIcinga + scopeTransport +) + +const ( + HeaderAccept = "Accept" + HeaderAuthorization = "Authorization" + HeaderContentType = "Content-Type" + HeaderUserAgent = "UserAgent" +) + +const ( + MIMETypeApplicationJSON = "application/json" +) + +// internal marker for builders to use in argument list +type builderOption interface { + Scope() int +} + +// clientOption is a builder option acting on [http.Client] instances +type clientOption interface { + builderOption + + // ApplyClient applies its internal logic onto the provided instance + ApplyClient(v *http.Client) +} + +// clientOptionFunc is a [clientOption] implementation +type clientOptionFunc func(*http.Client) + +// Scope implements [builderOption] and returns [scopeClient] +func (f clientOptionFunc) Scope() int { + return scopeClient +} + +// ApplyClient implements [clientOption] by calling f(v) +func (f clientOptionFunc) ApplyClient(v *http.Client) { + f(v) +} + +// WithClientTimeout sets the client timeout to the provided value +func WithClientTimeout(d time.Duration) clientOption { + return clientOptionFunc(func(c *http.Client) { + c.Timeout = d + }) +} + +// dialerOption is a builder option acting on [net.Dialer] instances +type dialerOption interface { + builderOption + + // ApplyDialer applies its internal logic onto the provided instance + ApplyDialer(v *net.Dialer) +} + +// dialerOptionFunc is a [dialerOption] implementation +type dialerOptionFunc func(*net.Dialer) + +// Scope implements [builderOption] and returns [scopeDialer] +func (f dialerOptionFunc) Scope() int { + return scopeDialer +} + +// ApplyDialer implements [dialerOption] by calling f(v) +func (f dialerOptionFunc) ApplyDialer(v *net.Dialer) { + f(v) +} + +// WithDialerTimeout sets the dialer timeout to the provided value +func WithDialerTimeout(d time.Duration) dialerOption { + return dialerOptionFunc(func(v *net.Dialer) { + v.Timeout = d + }) +} + +// WithDialerKeepAlive sets the KeepAlive flag on the dialer +func WithDialerKeepAlive(d time.Duration) dialerOption { + return dialerOptionFunc(func(v *net.Dialer) { + v.KeepAlive = d + }) +} + +// icingaOption is a builder option acting on [Client] instances +type icingaOption interface { + builderOption + + // ApplyIcinga applies its internal logic onto the provided instance + ApplyIcinga(v *Client) +} + +// icingaOptionFunc is a [icingaOption] implementation +type icingaOptionFunc func(*Client) + +// Scope implements [builderOption] and returns [scopeIcinga] +func (f icingaOptionFunc) Scope() int { + return scopeIcinga +} + +// ApplyIcinga implements [icingaOption] by calling f(v) +func (f icingaOptionFunc) ApplyIcinga(v *Client) { + f(v) +} + +// WithIcingaBasicAuth adds an authorization headers using the provided +// credentials with a "Basic" scheme +func WithIcingaBasicAuth(username, password string) icingaOption { + auth := username + ":" + password + token := base64.StdEncoding.EncodeToString([]byte(auth)) + + return icingaOptionFunc(func(v *Client) { + v.headers[HeaderAuthorization] = "Basic " + token + }) +} + +// WithIcingaUserAgent adds a user-agent header using the provided information +func WithIcingaUserAgent(application, version, metadata string) icingaOption { + ident := application + "/" + version + " (" + metadata + ")" + + return icingaOptionFunc(func(v *Client) { + v.headers[HeaderUserAgent] = ident + }) +} + +// WithIcingaHeader add a key/value pair as header to outgoing requests +func WithIcingaHeader(key, value string) icingaOption { + return icingaOptionFunc(func(v *Client) { + v.headers[key] = value + }) +} + +// WithIcingaAPIEndpoint adds the URL to the list of remote endpoints to connect to +func WithIcingaAPIEndpoint(url string) icingaOption { + return icingaOptionFunc(func(v *Client) { + v.IcingaURL = append(v.IcingaURL, url) + }) +} + +// WithIcingaAPIEndpoints adds the URLs to the list of remote endpoints to connect to +func WithIcingaAPIEndpoints(urls []string) icingaOption { + return icingaOptionFunc(func(v *Client) { + v.IcingaURL = append(v.IcingaURL, urls...) + }) +} + +// WithIcingaLogger changes the internal logger to the provided instance +func WithIcingaLogger(l *slog.Logger) icingaOption { + return icingaOptionFunc(func(v *Client) { + v.logger = l + }) +} + +// transportOption is a builder option acting on [http.Transport] instances +type transportOption interface { + builderOption + + // ApplyTransport applies its internal logic onto the provided instance + ApplyTransport(v *http.Transport) +} + +// transportOptionFunc is a [transportOption] implementation +type transportOptionFunc func(*http.Transport) + +// Scope implements [builderOption] and returns [scopeTransport] +func (f transportOptionFunc) Scope() int { + return scopeTransport +} + +// ApplyTransport implements [transportOption] by calling f(v) +func (f transportOptionFunc) ApplyTransport(v *http.Transport) { + f(v) +} + +// WithTransportDisableKeepAlives sets the DisableKeepAlives flag on the transport instance +func WithTransportDisableKeepAlives(b bool) transportOption { + return transportOptionFunc(func(v *http.Transport) { + v.DisableKeepAlives = b + }) +} + +// WithTransportForceAttemptHTTP2 sets the ForceAttemptHTTP2 flag on the transport instance +func WithTransportForceAttemptHTTP2(b bool) transportOption { + return transportOptionFunc(func(v *http.Transport) { + v.ForceAttemptHTTP2 = b + }) +} + +// WithTransportTLSHandshakeTimeout sets the TLS handshake timeout on the transport instance +func WithTransportTLSHandshakeTimeout(d time.Duration) transportOption { + return transportOptionFunc(func(v *http.Transport) { + v.TLSHandshakeTimeout = d + }) +} + +// WithTransportTLSClientConfig sets the TLS configuration on the transport instance +func WithTransportTLSClientConfig(c *tls.Config) transportOption { + return transportOptionFunc(func(v *http.Transport) { + v.TLSClientConfig = c + }) +} diff --git a/main.go b/main.go index 1d6bbe6..237204a 100644 --- a/main.go +++ b/main.go @@ -61,7 +61,14 @@ func main() { defer cancel() // Create Icinga Client - icingaClient := icinga2.NewClient(cfg, logger) + icingaClient := icinga2.NewClientWithOptions( + icinga2.WithIcingaBasicAuth(cfg.IcingaUser, cfg.IcingaPassword), + icinga2.WithIcingaAPIEndpoints(cfg.IcingaURL), + icinga2.WithIcingaLogger(logger), + icinga2.WithTransportDisableKeepAlives(cfg.IcingaDisableKeepAlives), + icinga2.WithTransportTLSClientConfig(cfg.IcingaTLSConfig), + icinga2.WithIcingaUserAgent("alertmanager-icinga-bridge", version, commit), + ) logger.Info("Starting alertmanager-icinga-bridge", "version", version, "commit", commit, "component", "main")