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 +}