424 lines
15 KiB
Plaintext
Raw Normal View History

#!/usr/bin/env python3
# eCall
# Bulk: yes
# -*- coding: utf-8 -*-
"eCall SMS notification plugin for Check_MK monitoring systems using rulebased notifications"
import argparse
import logging
import os
from urllib.parse import quote
from logging.handlers import TimedRotatingFileHandler
from cmk.notification_plugins.utils import (
collect_context,
read_bulk_contexts,
get_password_from_env_or_context,
substitute_context,
)
from cmk_addons.plugins.ecall.lib.api import SMS, Voice
class MessageFormat(object):
def __init__(self, shared, service=None, host=None):
self._shared = shared
self._service = service
self._host = host
@property
def service(self):
if self._shared and self._service:
return "{}\n{}".format(self._shared, self._service)
elif self._service: # shared is empty
return self._service
return self._shared
@property
def host(self):
if self._shared and self._host:
return "{}\n{}".format(self._shared, self._host)
elif self._host: # shared is empty
return self._host
return self._shared
class SettingsException(Exception):
pass
class Settings(object):
def __init__(self, notification_parameters):
p = notification_parameters
# keys that may just be copied:
# username, password, log_file, log_level, stdout
# handle simple mandatory parameters
self.username = get_password_from_env_or_context("USERNAME", p)
self.password = get_password_from_env_or_context("PASSWORD", p)
# in case of using the store, there will be None values when changes have not been activated
if self.username is None:
raise SettingsException(
"Got empty username. Did you activate changes after adding the user to the store?"
)
if self.password is None:
raise SettingsException(
"Got empty password. Did you activate changes after adding the password to the store?"
)
self.message_length_limit = int(p["CONTENT_LENGTH_LIMIT"])
self.log_file = os.path.expanduser(p["LOG_FILE"])
self.log_level = {
"d": logging.DEBUG,
"i": logging.INFO,
"w": logging.WARNING,
"c": logging.CRITICAL,
}.get(p["LOG_LEVEL"])
# handle simple optional parameters
if "PROXY_HOST" in p:
proto = p["PROXY_PROTOCOL"]
host = p["PROXY_HOST"]
port = p["PROXY_PORT"]
user = p.get("PROXY_AUTHENTICATION_USER")
if user:
user = quote(user)
if p.get("PROXY_AUTHENTICATION_PASSWORD_1"):
passwd = get_password_from_env_or_context("PROXY_AUTHENTICATION_PASSWORD", p)
passwd = quote(passwd)
if user and passwd:
url = f"{proto}://{user}:{passwd}@{host}:{port}"
elif user:
url = f"{proto}://{user}@{host}:{port}"
else:
url = f"{proto}://{host}:{port}"
self.proxy = {
"https": url
#proto: url
}
else:
self.proxy = None
if "SSL_SKIP_VERIFY" in p and p["SSL_SKIP_VERIFY"] == "True":
self.ssl_skip_verify = True
else:
self.ssl_skip_verify = False
if "STDOUT" in p and p["STDOUT"] == "True":
self.log_stdout = True
else:
self.log_stdout = False
# handle complex mandatory parameters
## parse message format
self.bulk_message_prefix = ""
if "CONTENT_BULK_PREFIX" in p:
self.bulk_message_prefix = p["CONTENT_BULK_PREFIX"]
message_format_shared = p["CONTENT_SHARED"]
message_format_specifc = {}
if "CONTENT_HOST" in p:
message_format_specifc["host"] = p["CONTENT_HOST"]
if "CONTENT_SERVICE" in p:
message_format_specifc["service"] = p["CONTENT_SERVICE"]
self.message_format = MessageFormat(message_format_shared, **message_format_specifc)
## iterate over every action parameter key in params
self.api_class = SMS if p["ACTION_1"] == "sms" else Voice
self.api_params = {}
for param_key in filter(lambda k: k.startswith("ACTION_2_"), p.keys()):
key = param_key.split("_")[2].lower()
val = p[param_key]
self.api_params[key] = val
if level := self.api_params.get("notificationlevel"):
self.api_params["notificationlevel"] = level.split("_")[1]
if nolog := self.api_params.get("nolog"):
if nolog == "True":
self.api_params["nolog"] = 1
else:
self.api_params["nolog"] = 0
class Notification(object):
def __init__(self, context, message_format, message_length_limit):
self._logger = logging.getLogger("ecall_plugin")
self._context = context
self._message_format = message_format
self._length_limit = message_length_limit
self.contact = context["CONTACTPAGER"]
def _limit(self, text):
self._logger.info("Limiting message length to at most %i characters", self._length_limit)
return text[: self._length_limit]
@property
def message(self):
if self._context["WHAT"] == "SERVICE":
message = substitute_context(self._message_format.service, self._context)
else:
message = substitute_context(self._message_format.host, self._context)
self._logger.debug("Unlimited message is '%s'", message)
return self._limit(message)
class BulkNotification(Notification):
def __init__(self, context_list, message_format, message_length_limit, bulk_message_prefix):
# call super to get shared properties
# just use the first context as reference for contact
# the official plugins also assume that contact is the same for every context
super(BulkNotification, self).__init__(
context_list[0], message_format, message_length_limit
)
self._prefix = bulk_message_prefix
self._context_list = context_list
def __format_message(self):
prefixes = []
affected_hosts = [] # TODO: This is currently not being used
service_states = {}
host_states = {}
self._logger.info("Processing bulk context list")
for context in self._context_list:
self._logger.debug("Processing context %s", context)
affected_host = context["HOSTNAME"]
# keep track of prefixes (if any)
if self._prefix:
# prefix is either HOSTGROUPNAMES or SERVICEGROUPNAMES
# Both are lists of group names separated by whitespace
prefixes += context[self._prefix].split(" ")
# fill list of affected hosts
if affected_host not in affected_hosts:
affected_hosts.append(affected_host)
# keep track of states
# TODO: We could code logic here to only send the most recent, worst state for each item
if context["WHAT"] == "SERVICE":
if context["PARSED_STATE"] not in service_states:
service_states[context["PARSED_STATE"]] = 1
else:
service_states[context["PARSED_STATE"]] += 1
else:
if context["PARSED_STATE"] not in host_states:
host_states[context["PARSED_STATE"]] = 1
else:
host_states[context["PARSED_STATE"]] += 1
# consolidate prefixes
prefixes = list(set(prefixes))
prefix_info = ""
if prefixes:
prefix_info = ", ".join(prefixes)
self._logger.debug("Got the following prefix for bulk message: %s", prefix_info)
prefix_info += ": "
# generate message
self._logger.debug("Raw service states are: %s", service_states)
self._logger.debug("Raw host states are: %s", host_states)
service_info = map(lambda i: "%s: %i" % (i[0].capitalize(), i[1]), service_states.items())
host_info = map(lambda i: "%s: %i" % (i[0].capitalize(), i[1]), host_states.items())
message_items = []
if host_info:
self._logger.debug("Processed host info is: %s", host_info)
message_items.append("Hosts %s" % ", ".join(host_info))
if service_info:
self._logger.debug("Processed service info is: %s", service_info)
message_items.append("Services %s" % ", ".join(service_info))
return prefix_info + ", ".join(message_items)
@property
def message(self):
# use base class when there is only one notification in bulk context
if len(self._context_list) == 1:
self._logger.info(
"Only one context in bulk notification, use default notification method"
)
return super(BulkNotification, self).message
return self._limit(self.__format_message())
class ContextParser(object):
"Parse variable inputs from checkmk notifier"
def __init__(self):
argument_parser = argparse.ArgumentParser(
description="Send eCall SMS from checkmk monitoring notifications."
)
argument_parser.add_argument(
"--bulk", help="Enable checkmk bulk mode", action="store_true", default=False
)
args = argument_parser.parse_args()
# TODO: maybe this should be configurable in the GUI.
# right now, the only notificationtype left is "PROBLEM".
# Only for "PROBLEM" the message format is actually being
# used. This is not optimal. May be let the user set a
# default message format and format and state per type of
# notification? This would be more flexible.
self.__special_states = {
"FLAPPINGSTART": ("WARNING", "started flapping"),
"FLAPPINGSTOP": ("OK", "stopped flapping"),
"ACKNOWLEDGEMENT": ("OK", "acknowledged"),
"RECOVERY": ("OK", "recovered"),
"DOWNTIMESTART": ("OK", "downtime started"),
"DOWNTIMECANCELLED": ("OK", "downtime cancelled"),
"DOWNTIMEEND": ("OK", "downtime ended"),
}
self.bulk = args.bulk
if args.bulk:
self.params, self.context = self.__parse_bulk()
else:
self.params, self.context = self.__parse_rulebased()
def __parse_filter_context(self, context, params, is_bulk=False):
"Add custom macros to the context and strip out any parameters"
what = context["WHAT"]
notification_type = context["NOTIFICATIONTYPE"]
context["PARSED_ITEM"] = context["HOSTNAME"]
if what == "SERVICE":
if params.get("SHOW_HOST_IF_SERVICE", "True") == "True":
context["PARSED_ITEM"] = "%s on %s" % (
context["SERVICEDESC"],
context["PARSED_ITEM"],
)
else:
context["PARSED_ITEM"] = context["SERVICEDESC"]
if notification_type in self.__special_states:
context["PARSED_STATE"], context["PARSED_OUTPUT"] = self.__special_states[
notification_type
]
if is_bulk:
# Since we are merely counting the notifications for each state
# it makes no sense to keep the original OK/WARN/CRIT/UP/DOWN here.
# Instead, we overwrite it with the pretty output (e.g. "started flapping")
# so that this is counted in the bulk message.
# e.g. Host Started flapping: 1, Downtime ended: 2, Down: 2, Service OK: 3, acknowledged: 4
context["PARSED_STATE"] = context["PARSED_OUTPUT"]
else:
context["PARSED_OUTPUT"] = context["%sOUTPUT" % what]
context["PARSED_STATE"] = context["%sSTATE" % what]
# return new context without parameters (since they would expose passwords)
return dict(filter(lambda i: not i[0].startswith("PARAMETER_"), context.items()))
def __parse_parameters(self, context):
params = {}
for param_key in filter(lambda k: k.startswith("PARAMETER_"), context.keys()):
params[param_key[10::]] = context[param_key]
return params
def __parse_rulebased(self):
context = collect_context()
params = self.__parse_parameters(context)
return params, self.__parse_filter_context(context, params)
def __parse_bulk(self):
params, context_list = read_bulk_contexts()
params = self.__parse_parameters(params)
# add custom fields to bulk contexts
# when there are no more than 1 contexts in the list, do not use bulk mode (--> is_bulk=False)
enriched_contexts = [
self.__parse_filter_context(context, params, is_bulk=len(context_list) > 1)
for context in context_list
]
return params, enriched_contexts
class Plugin(object):
"""
Check_MK notification plugin to notify events using eCall's HTTP/S API.
"""
def __init__(self):
parser = ContextParser()
settings = Settings(parser.params)
self._logger = logging.getLogger("ecall_plugin")
self.__configure_logging(settings.log_level, settings.log_file, settings.log_stdout)
self._logger.info("Starting eCall notification process")
self._logger.debug("Notification context is %s", parser.context)
if parser.bulk:
notification = BulkNotification(
parser.context,
settings.message_format,
settings.message_length_limit,
settings.bulk_message_prefix,
)
else:
notification = Notification(
parser.context, settings.message_format, settings.message_length_limit
)
self._logger.debug("Formatted message is %s", notification.message)
self._logger.debug("Initialising eCall API Object")
self.api = settings.api_class(
settings.username,
settings.password,
notification.contact,
notification.message,
requests_proxy_hosts=settings.proxy,
requests_verify_cert=not settings.ssl_skip_verify,
**settings.api_params,
)
self._logger.debug("Initialisation done")
def __configure_logging(self, log_level, log_file, log_stdout):
self._logger.setLevel(log_level)
log_format = logging.Formatter("%(asctime)s - %(levelname)s | %(message)s")
loghandler_file = TimedRotatingFileHandler(
log_file, when="midnight", interval=1, backupCount=10
)
loghandler_file.setFormatter(log_format)
loghandler_file.setLevel(log_level)
self._logger.addHandler(loghandler_file)
if log_stdout:
logger_console = logging.StreamHandler()
logger_console.setFormatter(log_format)
logger_console.setLevel(log_level)
self._logger.addHandler(logger_console)
def notify(self):
"""
Prepare input data and initiate notification sending
"""
self._logger.info("Notification process started")
self.api.send()
self._logger.info("Notification process finished")
Plugin().notify()