From 4162f5a0a1f1ad1d8f87146815c6e6e6e82365a5 Mon Sep 17 00:00:00 2001 From: Alvar Penning Date: Wed, 20 Dec 2023 15:19:37 +0100 Subject: [PATCH] Initial support for Icinga Notifications Inspired by the existing code for the Icinga DB, support for Icinga Notifications was added. Thus, there might be some level of code duplication between those two. The custom Icinga 2 configuration was sourced from the Icinga Notifications repository, but edited to not being parsed as a faulty Go template. To receive notifications, IcingaNotificationsWebhookReceiver can be used to launch a host-bound web server to act on Icinga Notification's Webhook channel. --- internal/services/icinga2/docker.go | 4 + internal/services/icingadb/docker_binary.go | 3 +- internal/services/notifications/docker.go | 159 ++++++++++ .../services/notifications/notifications.go | 33 ++ it.go | 102 ++++-- services/icinga2.go | 16 + services/icinga2_icinga_notifications.conf | 290 ++++++++++++++++++ services/icinga_notifications.go | 69 +++++ services/icinga_notifications_webhook_recv.go | 50 +++ services/mysql.go | 4 + services/postgresql.go | 13 +- services/relational_database.go | 3 + utils/docker.go | 30 ++ utils/network.go | 13 + 14 files changed, 766 insertions(+), 23 deletions(-) create mode 100644 internal/services/notifications/docker.go create mode 100644 internal/services/notifications/notifications.go create mode 100644 services/icinga2_icinga_notifications.conf create mode 100644 services/icinga_notifications.go create mode 100644 services/icinga_notifications_webhook_recv.go create mode 100644 utils/network.go diff --git a/internal/services/icinga2/docker.go b/internal/services/icinga2/docker.go index c472a71..5f1d669 100644 --- a/internal/services/icinga2/docker.go +++ b/internal/services/icinga2/docker.go @@ -189,6 +189,10 @@ func (n *dockerInstance) EnableIcingaDb(redis services.RedisServerBase) { services.Icinga2{Icinga2Base: n}.WriteIcingaDbConf(redis) } +func (n *dockerInstance) EnableIcingaNotifications(notis services.IcingaNotificationsBase) { + services.Icinga2{Icinga2Base: n}.WriteIcingaNotificationsConf(notis) +} + func (n *dockerInstance) Cleanup() { n.icinga2Docker.runningMutex.Lock() delete(n.icinga2Docker.running, n) diff --git a/internal/services/icingadb/docker_binary.go b/internal/services/icingadb/docker_binary.go index 4f28c92..dd3b810 100644 --- a/internal/services/icingadb/docker_binary.go +++ b/internal/services/icingadb/docker_binary.go @@ -11,7 +11,6 @@ import ( "github.com/icinga/icinga-testing/services" "github.com/icinga/icinga-testing/utils" "go.uber.org/zap" - "io/ioutil" "os" "path/filepath" "sync" @@ -67,7 +66,7 @@ func (i *dockerBinaryCreator) CreateIcingaDb( icingaDbDockerBinary: i, } - configFile, err := ioutil.TempFile("", "icingadb.yml") + configFile, err := os.CreateTemp("", "icingadb.yml") if err != nil { panic(err) } diff --git a/internal/services/notifications/docker.go b/internal/services/notifications/docker.go new file mode 100644 index 0000000..70882d8 --- /dev/null +++ b/internal/services/notifications/docker.go @@ -0,0 +1,159 @@ +package notifications + +import ( + "context" + "fmt" + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/network" + "github.com/docker/docker/client" + "github.com/icinga/icinga-testing/services" + "github.com/icinga/icinga-testing/utils" + "go.uber.org/zap" + "sync" + "sync/atomic" +) + +type dockerCreator struct { + logger *zap.Logger + dockerClient *client.Client + dockerNetworkId string + containerNamePrefix string + containerCounter uint32 + + runningMutex sync.Mutex + running map[*dockerInstance]struct{} +} + +var _ Creator = (*dockerCreator)(nil) + +func NewDockerCreator( + logger *zap.Logger, + dockerClient *client.Client, + containerNamePrefix string, + dockerNetworkId string, +) Creator { + return &dockerCreator{ + logger: logger.With(zap.Bool("icinga_notifications", true)), + dockerClient: dockerClient, + dockerNetworkId: dockerNetworkId, + containerNamePrefix: containerNamePrefix, + running: make(map[*dockerInstance]struct{}), + } +} + +func (i *dockerCreator) CreateIcingaNotifications( + rdb services.RelationalDatabase, + options ...services.IcingaNotificationsOption, +) services.IcingaNotificationsBase { + inst := &dockerInstance{ + info: info{ + port: defaultPort, + rdb: rdb, + }, + logger: i.logger, + icingaNotificationsDocker: i, + } + + idb := &services.IcingaNotifications{IcingaNotificationsBase: inst} + services.WithIcingaNotificationsDefaultsEnvConfig(inst.info.rdb, ":"+defaultPort)(idb) + for _, option := range options { + option(idb) + } + + containerName := fmt.Sprintf("%s-%d", i.containerNamePrefix, atomic.AddUint32(&i.containerCounter, 1)) + inst.logger = inst.logger.With(zap.String("container-name", containerName)) + networkName, err := utils.DockerNetworkName(context.Background(), i.dockerClient, i.dockerNetworkId) + if err != nil { + panic(err) + } + + dockerImage := utils.GetEnvDefault("ICINGA_TESTING_NOTIFICATIONS_IMAGE", "icinga-notifications:latest") + err = utils.DockerImagePull(context.Background(), inst.logger, i.dockerClient, dockerImage, false) + if err != nil { + panic(err) + } + + cont, err := i.dockerClient.ContainerCreate( + context.Background(), + &container.Config{ + Image: dockerImage, + Env: idb.ConfEnviron(), + }, + &container.HostConfig{}, + &network.NetworkingConfig{ + EndpointsConfig: map[string]*network.EndpointSettings{ + networkName: { + NetworkID: i.dockerNetworkId, + }, + }, + }, + nil, + containerName) + if err != nil { + inst.logger.Fatal("failed to create icinga-notifications container", zap.Error(err)) + } + inst.containerId = cont.ID + inst.logger = inst.logger.With(zap.String("container-id", cont.ID)) + inst.logger.Debug("created container") + + err = utils.ForwardDockerContainerOutput(context.Background(), i.dockerClient, cont.ID, + false, utils.NewLineWriter(func(line []byte) { + inst.logger.Debug("container output", + zap.ByteString("line", line)) + })) + if err != nil { + inst.logger.Fatal("failed to attach to container output", zap.Error(err)) + } + + err = i.dockerClient.ContainerStart(context.Background(), cont.ID, types.ContainerStartOptions{}) + if err != nil { + inst.logger.Fatal("failed to start container", zap.Error(err)) + } + inst.logger.Debug("started container") + + inst.info.host = utils.MustString(utils.DockerContainerAddress(context.Background(), i.dockerClient, cont.ID)) + + i.runningMutex.Lock() + i.running[inst] = struct{}{} + i.runningMutex.Unlock() + + return inst +} + +func (i *dockerCreator) Cleanup() { + i.runningMutex.Lock() + instances := make([]*dockerInstance, 0, len(i.running)) + for inst := range i.running { + instances = append(instances, inst) + } + i.runningMutex.Unlock() + + for _, inst := range instances { + inst.Cleanup() + } +} + +type dockerInstance struct { + info + icingaNotificationsDocker *dockerCreator + logger *zap.Logger + containerId string +} + +var _ services.IcingaNotificationsBase = (*dockerInstance)(nil) + +func (i *dockerInstance) Cleanup() { + i.icingaNotificationsDocker.runningMutex.Lock() + delete(i.icingaNotificationsDocker.running, i) + i.icingaNotificationsDocker.runningMutex.Unlock() + + err := i.icingaNotificationsDocker.dockerClient.ContainerRemove(context.Background(), i.containerId, types.ContainerRemoveOptions{ + Force: true, + RemoveVolumes: true, + }) + if err != nil { + panic(err) + } + i.logger.Debug("removed container") +} diff --git a/internal/services/notifications/notifications.go b/internal/services/notifications/notifications.go new file mode 100644 index 0000000..87eb582 --- /dev/null +++ b/internal/services/notifications/notifications.go @@ -0,0 +1,33 @@ +package notifications + +import ( + "github.com/icinga/icinga-testing/services" +) + +// defaultPort of the Icinga Notifications Web Listener. +const defaultPort string = "5680" + +type Creator interface { + CreateIcingaNotifications(rdb services.RelationalDatabase, options ...services.IcingaNotificationsOption) services.IcingaNotificationsBase + Cleanup() +} + +// info provides a partial implementation of the services.IcingaNotificationsBase interface. +type info struct { + host string + port string + + rdb services.RelationalDatabase +} + +func (i *info) Host() string { + return i.host +} + +func (i *info) Port() string { + return i.port +} + +func (i *info) RelationalDatabase() services.RelationalDatabase { + return i.rdb +} diff --git a/it.go b/it.go index 2d4c650..f89cfbc 100644 --- a/it.go +++ b/it.go @@ -13,17 +13,23 @@ // must be compiled using CGO_ENABLED=0 // - ICINGA_TESTING_ICINGADB_SCHEMA_MYSQL: Path to the full Icinga DB schema file for MySQL/MariaDB // - ICINGA_TESTING_ICINGADB_SCHEMA_PGSQL: Path to the full Icinga DB schema file for PostgreSQL +// - ICINGA_TESTING_ICINGA_NOTIFICATIONS_SCHEMA_PGSQL: Path to the full Icinga Notifications PostgreSQL schema file package icingatesting import ( "context" "flag" "fmt" + "os" + "sync" + "testing" + "github.com/docker/docker/api/types" "github.com/docker/docker/client" "github.com/icinga/icinga-testing/internal/services/icinga2" "github.com/icinga/icinga-testing/internal/services/icingadb" "github.com/icinga/icinga-testing/internal/services/mysql" + "github.com/icinga/icinga-testing/internal/services/notifications" "github.com/icinga/icinga-testing/internal/services/postgresql" "github.com/icinga/icinga-testing/internal/services/redis" "github.com/icinga/icinga-testing/services" @@ -31,9 +37,6 @@ import ( "go.uber.org/zap" "go.uber.org/zap/zapcore" "go.uber.org/zap/zaptest" - "os" - "sync" - "testing" ) // IT is the core type to start interacting with this module. @@ -50,18 +53,20 @@ import ( // m.Run() // } type IT struct { - mutex sync.Mutex - deferredCleanup []func() - prefix string - dockerClient *client.Client - dockerNetworkId string - mysql mysql.Creator - postgresql postgresql.Creator - redis redis.Creator - icinga2 icinga2.Creator - icingaDb icingadb.Creator - logger *zap.Logger - loggerDebugCore zapcore.Core + mutex sync.Mutex + deferredCleanup []func() + prefix string + dockerClient *client.Client + dockerNetworkId string + mysql mysql.Creator + postgresql postgresql.Creator + redis redis.Creator + icinga2 icinga2.Creator + icingaDb icingadb.Creator + icingaNotifications notifications.Creator + icingaNotificationsWebhookReceiver *services.IcingaNotificationsWebhookReceiver + logger *zap.Logger + loggerDebugCore zapcore.Core } var flagDebugLog = flag.String("icingatesting.debuglog", "", "file to write debug log to") @@ -272,9 +277,6 @@ func (it *IT) getIcingaDb() icingadb.Creator { } // IcingaDbInstance starts a new Icinga DB instance. -// -// It expects the ICINGA_TESTING_ICINGADB_BINARY environment variable to be set to the path of a precompiled icingadb -// binary which is then started in a new Docker container when this function is called. func (it *IT) IcingaDbInstance(redis services.RedisServer, rdb services.RelationalDatabase, options ...services.IcingaDbOption) services.IcingaDb { return services.IcingaDb{IcingaDbBase: it.getIcingaDb().CreateIcingaDb(redis, rdb, options...)} } @@ -288,6 +290,70 @@ func (it *IT) IcingaDbInstanceT( return i } +func (it *IT) getIcingaNotifications() notifications.Creator { + it.mutex.Lock() + defer it.mutex.Unlock() + + if it.icingaNotifications == nil { + it.icingaNotifications = notifications.NewDockerCreator(it.logger, it.dockerClient, it.prefix+"-icinga-notifications", it.dockerNetworkId) + it.deferCleanup(it.icingaNotifications.Cleanup) + } + + return it.icingaNotifications +} + +// IcingaNotificationsInstance starts a new Icinga Notifications instance. +func (it *IT) IcingaNotificationsInstance( + rdb services.RelationalDatabase, options ...services.IcingaNotificationsOption, +) services.IcingaNotifications { + return services.IcingaNotifications{ + IcingaNotificationsBase: it.getIcingaNotifications().CreateIcingaNotifications(rdb, options...), + } +} + +// IcingaNotificationsInstanceT creates a new Icinga Notifications instance and registers its cleanup function with testing.T. +func (it *IT) IcingaNotificationsInstanceT( + t testing.TB, rdb services.RelationalDatabase, options ...services.IcingaNotificationsOption, +) services.IcingaNotifications { + i := it.IcingaNotificationsInstance(rdb, options...) + t.Cleanup(i.Cleanup) + return i +} + +func (it *IT) getIcingaNotificationsWebhookReceiver() *services.IcingaNotificationsWebhookReceiver { + it.mutex.Lock() + defer it.mutex.Unlock() + + if it.icingaNotificationsWebhookReceiver == nil { + networkHost, err := utils.DockerNetworkHostAddress(context.Background(), it.dockerClient, it.dockerNetworkId) + if err != nil { + it.logger.Fatal("cannot get docker host address", zap.Error(err)) + } + port, err := utils.OpenTcpPort() + if err != nil { + it.logger.Fatal("cannot get an open TCP port", zap.Error(err)) + } + + webhookRec, err := services.LaunchIcingaNotificationsWebhookReceiver(fmt.Sprintf("%s:%d", networkHost, port)) + if err != nil { + it.logger.Fatal("cannot launch Icinga Notifications webhook receiver", zap.Error(err)) + } + + it.icingaNotificationsWebhookReceiver = webhookRec + it.deferCleanup(it.icingaNotificationsWebhookReceiver.Cleanup) + } + + return it.icingaNotificationsWebhookReceiver +} + +// IcingaNotificationsWebhookReceiverInstanceT creates a new Icinga Notifications Webhook Receiver instance and +// registers its cleanup function with testing.T. +func (it *IT) IcingaNotificationsWebhookReceiverInstanceT(t testing.TB) *services.IcingaNotificationsWebhookReceiver { + webhookRec := it.getIcingaNotificationsWebhookReceiver() + t.Cleanup(webhookRec.Cleanup) + return webhookRec +} + // Logger returns a *zap.Logger which additionally logs the current test case name. func (it *IT) Logger(t testing.TB) *zap.Logger { cores := []zapcore.Core{zaptest.NewLogger(t, zaptest.WrapOptions(zap.IncreaseLevel(zap.InfoLevel))).Core()} diff --git a/services/icinga2.go b/services/icinga2.go index f1c461b..a548b4c 100644 --- a/services/icinga2.go +++ b/services/icinga2.go @@ -40,6 +40,9 @@ type Icinga2Base interface { // EnableIcingaDb enables the icingadb feature on this node using the connection details of redis. EnableIcingaDb(redis RedisServerBase) + // EnableIcingaNotifications enables the Icinga Notifications integration with the custom configuration. + EnableIcingaNotifications(IcingaNotificationsBase) + // Cleanup stops the node and removes everything that was created to start this node. Cleanup() } @@ -128,3 +131,16 @@ func (i Icinga2) WriteIcingaDbConf(r RedisServerBase) { } i.WriteConfig(fmt.Sprintf("etc/icinga2/features-enabled/icingadb_%s_%s.conf", r.Host(), r.Port()), b.Bytes()) } + +//go:embed icinga2_icinga_notifications.conf +var icinga2IcingaNotificationsConfRawTemplate string +var icinga2IcingaNotificationsConfTemplate = template.Must(template.New("icinga-notifications.conf").Parse(icinga2IcingaNotificationsConfRawTemplate)) + +func (i Icinga2) WriteIcingaNotificationsConf(notis IcingaNotificationsBase) { + b := bytes.NewBuffer(nil) + err := icinga2IcingaNotificationsConfTemplate.Execute(b, notis) + if err != nil { + panic(err) + } + i.WriteConfig("etc/icinga2/features-enabled/icinga_notifications.conf", b.Bytes()) +} diff --git a/services/icinga2_icinga_notifications.conf b/services/icinga2_icinga_notifications.conf new file mode 100644 index 0000000..5dbbbe5 --- /dev/null +++ b/services/icinga2_icinga_notifications.conf @@ -0,0 +1,290 @@ +const IcingaNotificationsProcessEventUrl = "http://{{.Host}}:{{.Port}}/process-event" +const IcingaNotificationsIcingaWebUrl = "http://localhost/icingaweb2" +const IcingaNotificationsAuth = "source-1:correct horse battery staple" + +// urlencode a string loosely based on RFC 3986. +// +// Char replacement will be performed through a simple lookup table based on +// the RFC's chapters 2.2 and 2.3. This, however, is limited to ASCII. +function urlencode(str) { + var replacement = { + // gen-delims + ":" = "%3A", "/" = "%2F", "?" = "%3F", "#" = "%23", "[" = "%5B", "]" = "%5D", "@" = "%40" + + // sub-delims + "!" = "%21", "$" = "%24", "&" = "%26", "'" = "%27", "(" = "%28", ")" = "%29" + "*" = "%2A", "+" = "%2B", "," = "%2C", ";" = "%3B", "=" = "%3D" + + // additionals based on !unreserved + "\n" = "%0A", "\r" = "%0D", " " = "%20", "\"" = "%22" + } + + var pos = 0 + var out = "" + + while (pos < str.len()) { + var cur = str.substr(pos, 1) + out += replacement.contains(cur) ? replacement.get(cur) : cur + pos += 1 + } + + return out +} + +object User "icinga-notifications" { + # Workaround, types filter here must exclude Problem, otherwise no Acknowledgement notifications are sent. + # https://github.com/Icinga/icinga2/issues/9739 + types = [ Acknowledgement ] +} + +var baseBody = { + "curl" = { + order = -1 + set_if = {{`{{ true }}`}} + skip_key = true + value = {{`{{ + // Only send events that have either severity or type set, otherwise make it a no-op by executing true. + // This is used for preventing the EventCommand from sending invalid events for soft states. + (len(macro("$event_severity$")) > 0 || len(macro("$event_type$")) > 0) ? "curl" : "true" + }}`}} + } + "--user" = { value = IcingaNotificationsAuth } + "--fail" = { set_if = true } + "--silent" = { set_if = true } + "--show-error" = { set_if = true } + "url" = { + skip_key = true + value = IcingaNotificationsProcessEventUrl + } +} + +var hostBody = baseBody + { + "-d" = {{`{{ + var args = {} + args.tags.host = macro("$event_hostname$") + args.name = macro("$event_object_name$") + args.username = macro("$event_author$") + args.message = macro("$event_message$") + args.url = IcingaNotificationsIcingaWebUrl + "/icingadb/host?name=" + urlencode(macro("$host.name$")) + + var type = macro("$event_type$") + if (len(type) > 0) { + args.type = type + } + + var severity = macro("$event_severity$") + if (len(severity) > 0) { + args.severity = severity + } + + var extraTags = macro("$event_extra_tags$") + if (extraTags.len() > 0) { + args.extra_tags = extraTags + } + + return Json.encode(args) + }}`}} +} + +var hostExtraTags = {{`{{ + var tags = {} + for (group in host.groups) { + tags.set("hostgroup/" + group, null) + } + + return tags +}}`}} + +object NotificationCommand "icinga-notifications-host" use(hostBody, hostExtraTags) { + command = [ /* full command line generated from arguments */ ] + + arguments = hostBody + + vars += { + event_hostname = "$host.name$" + event_author = "$notification.author$" + event_message = "$notification.comment$" + event_object_name = "$host.display_name$" + event_extra_tags = hostExtraTags + } + + vars.event_type = {{`{{ + if (macro("$notification.type$") == "ACKNOWLEDGEMENT") { + return "acknowledgement" + } + + return "" + }}`}} + + vars.event_severity = {{`{{ + if (macro("$notification.type$") != "ACKNOWLEDGEMENT") { + return macro("$host.state$") == "DOWN" ? "crit" : "ok" + } + + return "" + }}`}} +} + +object EventCommand "icinga-notifications-host-events" use(hostBody, hostExtraTags) { + command = [ /* full command line generated from arguments */ ] + + arguments = hostBody + + vars += { + event_hostname = "$host.name$" + event_author = "" + event_message = "$host.output$" + event_object_name = "$host.display_name$" + event_extra_tags = hostExtraTags + } + + vars.event_severity = {{`{{ + if (macro("$host.state_type$") == "HARD") { + return macro("$host.state$") == "DOWN" ? "crit" : "ok" + } + + return "" + }}`}} +} + +template Host "generic-icinga-notifications-host" default { + event_command = "icinga-notifications-host-events" +} + +apply Notification "icinga-notifications-forwarder" to Host { + command = "icinga-notifications-host" + + types = [ Acknowledgement ] + + users = [ "icinga-notifications" ] + + assign where true +} + +var serviceBody = baseBody + { + "-d" = {{`{{ + var args = {} + args.tags.host = macro("$event_hostname$") + args.tags.service = macro("$event_servicename$") + args.name = macro("$event_object_name$") + args.username = macro("$event_author$") + args.message = macro("$event_message$") + args.url = IcingaNotificationsIcingaWebUrl + "/icingadb/service?name=" + urlencode(macro("$service.name$")) + "&host.name=" + urlencode(macro("$service.host.name$")) + + var type = macro("$event_type$") + if (len(type) > 0) { + args.type = type + } + + var severity = macro("$event_severity$") + if (len(severity) > 0) { + args.severity = severity + } + + var extraTags = macro("$event_extra_tags$") + if (extraTags.len() > 0) { + args.extra_tags = extraTags + } + + return Json.encode(args) + }}`}} +} + +var serviceExtraTags = {{`{{ + var tags = {} + for (group in service.host.groups) { + tags.set("hostgroup/" + group, null) + } + + for (group in service.groups) { + tags.set("servicegroup/" + group, null) + } + + return tags +}}`}} + +object NotificationCommand "icinga-notifications-service" use(serviceBody, serviceExtraTags) { + command = [ /* full command line generated from arguments */ ] + + arguments = serviceBody + + vars += { + event_hostname = "$service.host.name$" + event_servicename = "$service.name$" + event_author = "$notification.author$" + event_message = "$notification.comment$" + event_object_name = "$host.display_name$: $service.display_name$" + event_extra_tags = serviceExtraTags + } + + vars.event_type = {{`{{ + if (macro("$notification.type$") == "ACKNOWLEDGEMENT") { + return "acknowledgement" + } + + return "" + }}`}} + + vars.event_severity = {{`{{ + if (macro("$notification.type$") != "ACKNOWLEDGEMENT") { + var state = macro("$service.state$") + if (state == "OK") { + return "ok" + } else if (state == "WARNING") { + return "warning" + } else if (state == "CRITICAL") { + return "crit" + } else { // Unknown + return "err" + } + } + + return "" + }}`}} +} + +object EventCommand "icinga-notifications-service-events" use(serviceBody, serviceExtraTags) { + command = [ /* full command line generated from arguments */ ] + + arguments = serviceBody + + vars += { + event_hostname = "$service.host.name$" + event_servicename = "$service.name$" + event_author = "" + event_message = "$service.output$" + event_object_name = "$host.display_name$: $service.display_name$" + event_extra_tags = serviceExtraTags + } + + vars.event_severity = {{`{{ + if (macro("$service.state_type$") == "HARD") { + var state = macro("$service.state$") + if (state == "OK") { + return "ok" + } else if (state == "WARNING") { + return "warning" + } else if (state == "CRITICAL") { + return "crit" + } else { // Unknown + return "err" + } + } + + return "" + }}`}} +} + +template Service "generic-icinga-notifications-service" default { + event_command = "icinga-notifications-service-events" +} + +apply Notification "icinga-notifications-forwarder" to Service { + command = "icinga-notifications-service" + + types = [ Acknowledgement ] + + users = [ "icinga-notifications" ] + + assign where true +} diff --git a/services/icinga_notifications.go b/services/icinga_notifications.go new file mode 100644 index 0000000..686ed42 --- /dev/null +++ b/services/icinga_notifications.go @@ -0,0 +1,69 @@ +package services + +import ( + _ "embed" +) + +type IcingaNotificationsBase interface { + // Host returns the host on which Icinga Notification's listener can be reached. + Host() string + + // Port return the port on which Icinga Notification's listener can be reached. + Port() string + + // RelationalDatabase returns the instance information of the relational database this instance is using. + RelationalDatabase() RelationalDatabase + + // Cleanup stops the instance and removes everything that was created to start it. + Cleanup() +} + +// IcingaNotifications wraps the IcingaNotificationsBase interface and adds some more helper functions. +type IcingaNotifications struct { + IcingaNotificationsBase + Environ map[string]string +} + +// ConfEnviron returns configuration environment variables. +func (i IcingaNotifications) ConfEnviron() []string { + envs := make([]string, 0, len(i.Environ)) + for k, v := range i.Environ { + envs = append(envs, k+"="+v) + } + return envs +} + +// IcingaNotificationsOption configures IcingaNotifications. +type IcingaNotificationsOption func(*IcingaNotifications) + +// WithIcingaNotificationsDefaultsEnvConfig populates the configuration environment variables with useful defaults. +// +// This will always be applied before any other IcingaNotificationsOption. +func WithIcingaNotificationsDefaultsEnvConfig(rdb RelationalDatabase, listenAddr string) IcingaNotificationsOption { + return func(notifications *IcingaNotifications) { + if notifications.Environ == nil { + notifications.Environ = make(map[string]string) + } + + notifications.Environ["ICINGA_NOTIFICATIONS_LISTEN"] = listenAddr + notifications.Environ["ICINGA_NOTIFICATIONS_CHANNEL-PLUGIN-DIR"] = "/usr/libexec/icinga-notifications/channel" + notifications.Environ["ICINGA_NOTIFICATIONS_DATABASE_TYPE"] = rdb.IcingaDbType() + notifications.Environ["ICINGA_NOTIFICATIONS_DATABASE_HOST"] = rdb.Host() + notifications.Environ["ICINGA_NOTIFICATIONS_DATABASE_PORT"] = rdb.Port() + notifications.Environ["ICINGA_NOTIFICATIONS_DATABASE_DATABASE"] = rdb.Database() + notifications.Environ["ICINGA_NOTIFICATIONS_DATABASE_USER"] = rdb.Username() + notifications.Environ["ICINGA_NOTIFICATIONS_DATABASE_PASSWORD"] = rdb.Password() + notifications.Environ["ICINGA_NOTIFICATIONS_LOGGING_LEVEL"] = "debug" + } +} + +// WithIcingaNotificationsEnvConfig sets an environment variable configuration for icinga-notifications. +func WithIcingaNotificationsEnvConfig(key, value string) IcingaNotificationsOption { + return func(notifications *IcingaNotifications) { + if notifications.Environ == nil { + notifications.Environ = make(map[string]string) + } + + notifications.Environ[key] = value + } +} diff --git a/services/icinga_notifications_webhook_recv.go b/services/icinga_notifications_webhook_recv.go new file mode 100644 index 0000000..21d36ec --- /dev/null +++ b/services/icinga_notifications_webhook_recv.go @@ -0,0 +1,50 @@ +package services + +import ( + "context" + "net/http" + "time" +) + +// IcingaNotificationsWebhookReceiver is a minimal HTTP web server for the Icinga Notifications Webhook channel. +// +// After being launched, bound on the host to the Docker Network's "Gateway" IPv4 address, incoming requests will be +// passed to the Handler http.HandlerFunc which MUST be set to the custom receiver. +type IcingaNotificationsWebhookReceiver struct { + ListenAddr string + Handler http.HandlerFunc + server *http.Server +} + +// LaunchIcingaNotificationsWebhookReceiver starts an IcingaNotificationsWebhookReceiver's webserver on the listen address. +func LaunchIcingaNotificationsWebhookReceiver(listen string) (*IcingaNotificationsWebhookReceiver, error) { + webhookRec := &IcingaNotificationsWebhookReceiver{ + ListenAddr: listen, + Handler: func(writer http.ResponseWriter, request *http.Request) { + // Default handler to not run into nil pointer dereference errors. + _ = request.Body.Close() + http.Error(writer, "¯\\_(ツ)_/¯", http.StatusServiceUnavailable) + }, + } + webhookRec.server = &http.Server{ + Addr: listen, + Handler: &webhookRec.Handler, + } + + errCh := make(chan error) + go func() { errCh <- webhookRec.server.ListenAndServe() }() + + select { + case err := <-errCh: + return nil, err + case <-time.After(time.Second): + return webhookRec, nil + } +} + +// Cleanup closes both the web server and the Requests channel. +func (webhookRec *IcingaNotificationsWebhookReceiver) Cleanup() { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + _ = webhookRec.server.Shutdown(ctx) +} diff --git a/services/mysql.go b/services/mysql.go index 153e183..865e151 100644 --- a/services/mysql.go +++ b/services/mysql.go @@ -73,3 +73,7 @@ func (m MysqlDatabase) ImportIcingaDbSchema() { } } } + +func (m MysqlDatabase) ImportIcingaNotificationsSchema() { + panic("icinga-notifications does not support MySQL yet") +} diff --git a/services/postgresql.go b/services/postgresql.go index ff85aef..4313bd5 100644 --- a/services/postgresql.go +++ b/services/postgresql.go @@ -59,8 +59,7 @@ func (p PostgresqlDatabase) Open() (*sql.DB, error) { return sql.Open(p.Driver(), p.DSN()) } -func (p PostgresqlDatabase) ImportIcingaDbSchema() { - key := "ICINGA_TESTING_ICINGADB_SCHEMA_PGSQL" +func (p PostgresqlDatabase) importSchema(key string) { schemaFile, ok := os.LookupEnv(key) if !ok { panic(fmt.Errorf("environment variable %s must be set", key)) @@ -68,7 +67,7 @@ func (p PostgresqlDatabase) ImportIcingaDbSchema() { schema, err := os.ReadFile(schemaFile) if err != nil { - panic(fmt.Errorf("failed to read icingadb schema file %q: %w", schemaFile, err)) + panic(fmt.Errorf("failed to read %s schema file %q: %w", key, schemaFile, err)) } db, err := PostgresqlDatabase{PostgresqlDatabaseBase: p}.Open() @@ -79,3 +78,11 @@ func (p PostgresqlDatabase) ImportIcingaDbSchema() { panic(err) } } + +func (p PostgresqlDatabase) ImportIcingaDbSchema() { + p.importSchema("ICINGA_TESTING_ICINGADB_SCHEMA_PGSQL") +} + +func (p PostgresqlDatabase) ImportIcingaNotificationsSchema() { + p.importSchema("ICINGA_TESTING_ICINGA_NOTIFICATIONS_SCHEMA_PGSQL") +} diff --git a/services/relational_database.go b/services/relational_database.go index 3793d26..8ebbf1c 100644 --- a/services/relational_database.go +++ b/services/relational_database.go @@ -28,6 +28,9 @@ type RelationalDatabase interface { // ImportIcingaDbSchema imports the Icinga DB schema into this database. ImportIcingaDbSchema() + // ImportIcingaNotificationsSchema imports the Icinga Notifications schema into this database. + ImportIcingaNotificationsSchema() + // Cleanup removes the database. Cleanup() } diff --git a/utils/docker.go b/utils/docker.go index 229aa5c..a117f5f 100644 --- a/utils/docker.go +++ b/utils/docker.go @@ -34,6 +34,36 @@ func DockerNetworkName(ctx context.Context, client *client.Client, id string) (s return net.Name, nil } +// DockerNetworkHostAddress returns the host's IPv4 address on this Docker network, aka the gateway address. +// +// A service running on the host bound to this IP address (or 0.0.0.0) will be available within this Docker network +// under this address for the containers. +// +// Note: In case of a configured firewall, one might need to allow incoming connections from Docker. For example: +// +// # Allow all TCP ports from Docker, specified as the docker0 interface, to the host: +// iptables -I INPUT -i docker0 -p tcp -j ACCEPT +// +// # Allow only connections to the TCP port 8080: +// iptables -I INPUT -i docker0 -p tcp --dport 8080 -j ACCEPT +// +// # Within a custom docker network, it might become necessary to allow incoming connections from a bridge interface. +// # The following command would allow all local TCP ports from all interfaces starting with "br-": +// iptables -I INPUT -i br-+ -p tcp -j ACCEPT +func DockerNetworkHostAddress(ctx context.Context, client *client.Client, id string) (string, error) { + net, err := client.NetworkInspect(ctx, id, types.NetworkInspectOptions{}) + if err != nil { + return "", err + } + + ipamConfs := net.IPAM.Config + if len(ipamConfs) != 1 { + return "", fmt.Errorf("docker network %q has not one IPAM config, but %d", id, len(ipamConfs)) + } + + return ipamConfs[0].Gateway, nil +} + // ForwardDockerContainerOutput attaches to a docker container and forwards all its output to a writer. func ForwardDockerContainerOutput( ctx context.Context, client *client.Client, containerId string, logs bool, w io.Writer, diff --git a/utils/network.go b/utils/network.go new file mode 100644 index 0000000..e37e6be --- /dev/null +++ b/utils/network.go @@ -0,0 +1,13 @@ +package utils + +import "net" + +// OpenTcpPort returns an open TCP port to be bound to. +func OpenTcpPort() (int, error) { + listener, err := net.Listen("tcp", "localhost:0") + if err != nil { + return 0, err + } + defer func() { _ = listener.Close() }() + return listener.Addr().(*net.TCPAddr).Port, nil +}