Skip to content

Commit

Permalink
Initial support for Icinga Notifications
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
oxzi committed Jan 18, 2024
1 parent 18da892 commit 4162f5a
Show file tree
Hide file tree
Showing 14 changed files with 766 additions and 23 deletions.
4 changes: 4 additions & 0 deletions internal/services/icinga2/docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 1 addition & 2 deletions internal/services/icingadb/docker_binary.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
}
Expand Down
159 changes: 159 additions & 0 deletions internal/services/notifications/docker.go
Original file line number Diff line number Diff line change
@@ -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")
}
33 changes: 33 additions & 0 deletions internal/services/notifications/notifications.go
Original file line number Diff line number Diff line change
@@ -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
}
102 changes: 84 additions & 18 deletions it.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,27 +13,30 @@
// 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"
"github.com/icinga/icinga-testing/utils"
"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.
Expand All @@ -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")
Expand Down Expand Up @@ -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...)}
}
Expand All @@ -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()}
Expand Down
16 changes: 16 additions & 0 deletions services/icinga2.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down Expand Up @@ -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())
}
Loading

0 comments on commit 4162f5a

Please sign in to comment.