From 2c2af6bed99c0582f7e0e62127d2ba9d840a74a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Fra=C3=9F?= Date: Wed, 30 Nov 2022 10:26:27 +0100 Subject: [PATCH] [add] notification-channel "libnotify" [add] notification-channel "file_touch" [add] check-kind "file_timestamp" [mod] threshold + annoy --- hmdl.schema.json | 614 +++++++++++++-------- source/check_kinds/file_timestamp.py | 65 +++ source/check_kinds/test.py | 24 - source/main.py | 105 ++-- source/notification_channels/_interface.py | 4 + source/notification_channels/console.py | 11 + source/notification_channels/email.py | 11 + source/notification_channels/file_touch.py | 19 + source/notification_channels/libnotify.py | 81 +++ test.hmdl.json | 15 +- todo.md | 5 +- tools/build | 4 +- 12 files changed, 658 insertions(+), 300 deletions(-) create mode 100644 source/check_kinds/file_timestamp.py delete mode 100644 source/check_kinds/test.py create mode 100644 source/notification_channels/file_touch.py create mode 100644 source/notification_channels/libnotify.py diff --git a/hmdl.schema.json b/hmdl.schema.json index b6fde4e..2a4ff15 100644 --- a/hmdl.schema.json +++ b/hmdl.schema.json @@ -1,116 +1,393 @@ { "$defs": { "active": { - "type": "boolean" + "type": "boolean", + "default": true + }, + "threshold": { + "description": "how often a condition has to occur in order to be reported", + "type": "integer", + "minimum": 1, + "default": 3 + }, + "annoy": { + "description": "whether notifications shall be kept sending after the threshold has been surpassed", + "type": "boolean", + "default": false }, "schedule": { "type": "object", "additionalProperties": false, "properties": { "regular_interval": { - "description": "in seconds", - "type": "integer" + "description": "in seconds or as text: minute, hour, day, week", + "type": ["integer", "string"] }, "attentive_interval": { - "description": "in seconds", - "type": "integer" + "description": "in seconds or as text: minute, hour, day, week", + "type": ["integer", "string"] } }, "required": [ "regular_interval" ] }, + "check_kind_test": { + "type": "object", + "additionalProperties": false, + "properties": { + "kind": { + "type": "string", + "const": "test" + }, + "parameters": { + "type": "object", + "additionalProperties": false, + "properties": { + "condition": { + "type": "string", + "enum": [ + "unknown", + "ok", + "warning", + "critical" + ], + "default": "warning" + }, + "output": { + "type": "string", + "default": "" + } + }, + "required": [ + ] + } + }, + "required": [ + "kind", + "parameters" + ] + }, + "check_kind_script": { + "type": "object", + "additionalProperties": false, + "properties": { + "kind": { + "type": "string", + "const": "script" + }, + "parameters": { + "type": "object", + "additionalProperties": false, + "properties": { + "path": { + "type": "string" + }, + "arguments": { + "type": "array", + "item": { + "type": "string" + } + } + }, + "required": [ + "path" + ] + } + }, + "required": [ + "kind", + "parameters" + ] + }, + "check_kind_file_timestamp": { + "type": "object", + "additionalProperties": false, + "properties": { + "kind": { + "type": "string", + "const": "file_timestamp" + }, + "parameters": { + "type": "object", + "additionalProperties": false, + "properties": { + "path": { + "type": "string" + }, + "warning_age": { + "type": "integer", + "exclusiveMinimum": 0, + "default": 3600 + }, + "critical_age": { + "type": "integer", + "exclusiveMinimum": 0, + "default": 86400 + }, + "condition_on_missing": { + "description": "which condition to report if file is missing", + "type": "string", + "enum": [ + "unknown", + "ok", + "warning", + "critical" + ], + "default": "warning" + }, + "condition_on_implausible": { + "description": "which condition to report if the age is negative, i.e. the file is apparently from the future", + "type": "string", + "enum": [ + "unknown", + "ok", + "warning", + "critical" + ], + "default": "warning" + } + }, + "required": [ + "path" + ] + } + }, + "required": [ + "kind", + "parameters" + ] + }, + "check_kind_http_request": { + "type": "object", + "additionalProperties": false, + "properties": { + "kind": { + "type": "string", + "const": "http_request" + }, + "parameters": { + "type": "object", + "additionalProperties": false, + "properties": { + "request": { + "type": "object", + "additionalProperties": false, + "properties": { + "target": { + "description": "URL", + "type": "string" + }, + "method": { + "type": "string", + "enum": [ + "GET", + "POST" + ], + "default": "GET" + } + }, + "required": [ + "target" + ] + }, + "response": { + "type": "object", + "additionalProperties": false, + "properties": { + "status_code": { + "description": "checks whether the response status code is this", + "type": ["null", "integer"], + "default": 200 + }, + "headers": { + "description": "conjunctively checks header key-value pairs", + "type": "object", + "additionalProperties": { + "description": "header value", + "type": "string" + }, + "properties": { + }, + "required": [ + ], + "default": {} + }, + "body_part": { + "description": "checks whether the response body contains this string", + "type": "string" + } + }, + "required": [ + ] + }, + "as_warning": { + "description": "whether a violation of this check shall be exposed as warning instead of critical; default: false", + "type": "boolean", + "default": false + } + }, + "required": [ + "request" + ] + } + }, + "required": [ + "kind", + "parameters" + ] + }, + "notification_channel_console": { + "type": "object", + "additionalProperties": false, + "properties": { + "kind": { + "type": "string", + "const": "console" + }, + "parameters": { + "type": "object", + "additionalProperties": false, + "properties": { + }, + "required": [ + ] + } + }, + "required": [ + "kind", + "parameters" + ] + }, + "notification_channel_file_touch": { + "type": "object", + "additionalProperties": false, + "properties": { + "kind": { + "type": "string", + "const": "file_touch" + }, + "parameters": { + "type": "object", + "additionalProperties": false, + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ] + } + }, + "required": [ + "kind", + "parameters" + ] + }, + "notification_channel_libnotify": { + "type": "object", + "additionalProperties": false, + "properties": { + "kind": { + "type": "string", + "const": "libnotify" + }, + "parameters": { + "type": "object", + "additionalProperties": false, + "properties": { + "icon": { + "type": "string" + } + }, + "required": [ + ] + } + }, + "required": [ + "kind", + "parameters" + ] + }, + "notification_channel_email": { + "type": "object", + "additionalProperties": false, + "properties": { + "kind": { + "type": "string", + "const": "email" + }, + "parameters": { + "type": "object", + "additionalProperties": false, + "properties": { + "access": { + "type": "object", + "additionalProperties": false, + "properties": { + "host": { + "type": "string" + }, + "port": { + "type": "integer" + }, + "username": { + "type": "string" + }, + "password": { + "type": "string" + } + }, + "required": [ + "host", + "port", + "username", + "password" + ] + }, + "sender": { + "type": "string" + }, + "receivers": { + "type": "array", + "item": { + "type": "string" + } + }, + "tags": { + "description": "list of strings, which will be placed in the e-mail subject", + "type": "array", + "item": { + "type": "string" + }, + "default": [] + } + }, + "required": [ + "access", + "sender", + "receivers" + ] + } + }, + "required": [ + "kind", + "parameters" + ] + }, "notifications": { "type": "array", "item": { "anyOf": [ { - "type": "object", - "additionalProperties": false, - "properties": { - "kind": { - "type": "string", - "const": "console" - }, - "parameters": { - "type": "object", - "additionalProperties": false, - "properties": { - }, - "required": [ - ] - } - }, - "required": [ - "kind", - "parameters" - ] + "$ref": "#/$defs/notification_channel_console" }, { - "type": "object", - "additionalProperties": false, - "properties": { - "kind": { - "type": "string", - "const": "email" - }, - "parameters": { - "type": "object", - "additionalProperties": false, - "properties": { - "access": { - "type": "object", - "additionalProperties": false, - "properties": { - "host": { - "type": "string" - }, - "port": { - "type": "integer" - }, - "username": { - "type": "string" - }, - "password": { - "type": "string" - } - }, - "required": [ - "host", - "port", - "username", - "password" - ] - }, - "sender": { - "type": "string" - }, - "receivers": { - "type": "array", - "item": { - "type": "string" - } - }, - "tags": { - "description": "list of strings, which will be placed in the e-mail subject", - "type": "array", - "item": { - "type": "string" - }, - "default": [] - } - }, - "required": [ - "access", - "sender", - "receivers" - ] - } - }, - "required": [ - "kind", - "parameters" - ] + "$ref": "#/$defs/notification_channel_file_touch" + }, + { + "$ref": "#/$defs/notification_channel_email" } ] }, @@ -134,6 +411,12 @@ "active": { "$ref": "#/$defs/active" }, + "threshold": { + "$ref": "#/$defs/threshold" + }, + "annoy": { + "$ref": "#/$defs/annoy" + }, "schedule": { "$ref": "#/$defs/schedule" }, @@ -159,6 +442,12 @@ "active": { "$ref": "#/$defs/active" }, + "threshold": { + "$ref": "#/$defs/threshold" + }, + "annoy": { + "$ref": "#/$defs/annoy" + }, "schedule": { "$ref": "#/$defs/schedule" }, @@ -172,151 +461,16 @@ { "anyOf": [ { - "type": "object", - "additionalProperties": false, - "properties": { - "kind": { - "type": "string", - "const": "test" - }, - "parameters": { - "type": "object", - "additionalProperties": false, - "properties": { - "condition": { - "type": "string", - "enum": [ - "unknown", - "ok", - "warning", - "critical" - ], - "default": "warning" - }, - "output": { - "type": "string", - "default": "" - } - }, - "required": [ - ] - } - }, - "required": [ - "kind", - "parameters" - ] + "$ref": "#/$defs/check_kind_test" }, { - "type": "object", - "additionalProperties": false, - "properties": { - "kind": { - "type": "string", - "const": "script" - }, - "parameters": { - "type": "object", - "additionalProperties": false, - "properties": { - "path": { - "type": "string" - }, - "arguments": { - "type": "array", - "item": { - "type": "string" - } - } - }, - "required": [ - "path" - ] - } - }, - "required": [ - "kind", - "parameters" - ] + "$ref": "#/$defs/check_kind_script" }, { - "type": "object", - "additionalProperties": false, - "properties": { - "kind": { - "type": "string", - "const": "http_request" - }, - "parameters": { - "type": "object", - "additionalProperties": false, - "properties": { - "request": { - "type": "object", - "additionalProperties": false, - "properties": { - "target": { - "description": "URL", - "type": "string" - }, - "method": { - "type": "string", - "enum": [ - "GET", - "POST" - ], - "default": "GET" - } - }, - "required": [ - "target" - ] - }, - "response": { - "type": "object", - "additionalProperties": false, - "properties": { - "status_code": { - "description": "checks whether the response status code is this", - "type": ["null", "integer"], - "default": 200 - }, - "headers": { - "description": "conjunctively checks header key-value pairs", - "type": "object", - "additionalProperties": { - "description": "header value", - "type": "string" - }, - "properties": { - }, - "required": [ - ], - "default": {} - }, - "body_part": { - "description": "checks whether the response body contains this string", - "type": "string" - } - }, - "required": [ - ] - }, - "as_warning": { - "description": "whether a violation of this check shall be exposed as warning instead of critical; default: false", - "type": "boolean", - "default": false - } - }, - "required": [ - "request" - ] - } - }, - "required": [ - "kind", - "parameters" - ] + "$ref": "#/$defs/check_kind_file_timestamp" + }, + { + "$ref": "#/$defs/check_kind_http_request" } ] } diff --git a/source/check_kinds/file_timestamp.py b/source/check_kinds/file_timestamp.py new file mode 100644 index 0000000..1748acc --- /dev/null +++ b/source/check_kinds/file_timestamp.py @@ -0,0 +1,65 @@ +class implementation_check_kind_file_timestamp(interface_check_kind): + + ''' + [implementation] + ''' + def normalize_conf_node(self, node): + if ("path" not in node): + raise ValueError("missing mandatory field 'path'") + else: + return dict_merge( + { + "warning_age": (60 * 60), + "critical_age": (60 * 60 * 24), + "condition_on_missing": condition_encode(enum_condition.warning), + "condition_on_implausible": condition_encode(enum_condition.warning), + }, + node + ) + + + ''' + [implementation] + ''' + def run(self, parameters): + if (not _os.path.exists(parameters["path"])): + return { + "condition": condition_decode(parameters["condition_on_missing"]), + "output": "file is missing" + } + else: + result = _os.stat(parameters["path"]) + timestamp = get_current_timestamp() + age = (timestamp - result.st_atime) + if (age < 0): + return { + "condition": condition_decode(parameters["condition_on_implausible"]), + "output": string_coin( + "file is apparently from the future; timestamp of checking instance: {{timestamp_this}}; timestamp of file: {{timestamp_that}} (age in seconds: {{age}})", + { + "timestamp_this": timestamp, + "timestamp_that": result.st_atime, + "age": ("%u" % age), + } + ), + } + else: + if ((age > 0) and (age <= parameters["warning_age"])): + condition = enum_condition.ok + elif ((age > parameters["warning_age"]) and (age <= parameters["critical_age"])): + condition = enum_condition.warning + elif (age > parameters["critical_age"]): + condition = enum_condition.critical + else: + raise ValueError("impossible state") + return { + "condition": condition, + "output": string_coin( + "age in seconds: {{age}}", + { + "age": ("%u" % age), + } + ), + } + + diff --git a/source/check_kinds/test.py b/source/check_kinds/test.py deleted file mode 100644 index c7a1946..0000000 --- a/source/check_kinds/test.py +++ /dev/null @@ -1,24 +0,0 @@ -class implementation_check_kind_test(interface_check_kind): - - ''' - [implementation] - ''' - def normalize_conf_node(self, node): - return dict_merge( - { - "condition": condition_encode(enum_condition.warning), - "output": "", - }, - node - ) - - - ''' - [implementation] - ''' - def run(self, parameters): - return { - "condition": condition_decode(parameters["condition"]), - "output": parameters["output"] - } - diff --git a/source/main.py b/source/main.py index 596807e..87414cb 100644 --- a/source/main.py +++ b/source/main.py @@ -14,6 +14,55 @@ def state_decode(state_encoded): } +def conf_normalize_interval(interval_raw): + if (type(interval_raw) == int): + return interval_raw + elif (type(interval_raw) == str): + if (interval_raw == "minute"): + return (60) + elif (interval_raw == "hour"): + return (60 * 60) + elif (interval_raw == "day"): + return (60 * 60 * 24) + elif (interval_raw == "week"): + return (60 * 60 * 24 * 7) + else: + raise ValueError("invalid string interval value: %s" % interval_raw) + else: + raise ValueError("invalid type for interval value") + + +def conf_normalize_schedule(node): + node_ = dict_merge( + { + "regular_interval": (60 * 60), + "attentive_interval": (60 * 2), + }, + node + ) + return { + "regular_interval": conf_normalize_interval(node["regular_interval"]), + "attentive_interval": conf_normalize_interval(node["attentive_interval"]), + } + + +def conf_normalize_defaults(node): + return dict_merge( + { + "active": True, + "threshold": 3, + "annoy": False, + "schedule": { + "regular_interval": (60 * 60), + "attentive_interval": (60 * 2), + }, + "notifications": [ + ], + }, + node + ) + + def conf_normalize_check(check_kind_implementations, defaults, name, node): if ("kind" not in node): raise ValueError("missing mandatory 'check' field 'kind'") @@ -25,6 +74,8 @@ def conf_normalize_check(check_kind_implementations, defaults, name, node): { "title": name, "active": defaults["active"], + "threshold": defaults["threshold"], + "annoy": defaults["annoy"], "schedule": defaults["schedule"], "notifications": defaults["notifications"], "parameters": {}, @@ -34,28 +85,15 @@ def conf_normalize_check(check_kind_implementations, defaults, name, node): return { "title": node_["title"], "active": node_["active"], - "schedule": node_["schedule"], + "threshold": node_["threshold"], + "annoy": node_["annoy"], + "schedule": conf_normalize_schedule(node_["schedule"]), "notifications": node_["notifications"], "kind": node_["kind"], "parameters": check_kind_implementations[node_["kind"]].normalize_conf_node(node_["parameters"]), } -def conf_normalize_defaults(node): - return dict_merge( - { - "active": True, - "schedule": { - "regular_interval": (60 * 60), - "attentive_interval": (60 * 2), - }, - "notifications": [ - ], - }, - node - ) - - def conf_normalize_root(check_kind_implementations, node): return dict( map( @@ -105,23 +143,6 @@ def main(): dest = "erase_state", help = "whether the state shall be deleted on start; this will cause that all checks are executed" ) - argumentparser.add_argument( - "-t", - "--threshold", - type = int, - default = 3, - dest = "threshold", - metavar = "", - help = "how often a condition has to occur in order to be reported" - ) - argumentparser.add_argument( - "-k", - "--keep-notifying", - action = "store_true", - default = False, - dest = "keep_notifying", - help = "whether notifications shall be kept sending after the threshold has been surpassed" - ) argumentparser.add_argument( "-e", "--expose-full-conf", @@ -149,15 +170,17 @@ def main(): ### load check kind implementations check_kind_implementations = { - "test": implementation_check_kind_test(), "script": implementation_check_kind_script(), + "file_timestamp": implementation_check_kind_file_timestamp(), "http_request": implementation_check_kind_http_request(), } ### load notification channel implementations notification_channel_implementations = { "console": implementation_notification_channel_console(), + "file_touch": implementation_notification_channel_file_touch(), "email": implementation_notification_channel_email(), + "libnotify": implementation_notification_channel_libnotify(), } ### get configuration data @@ -194,14 +217,12 @@ def main(): or (old_item_state["condition"] != enum_condition.ok) or + ((timestamp - old_item_state["timestamp"]) >= check_data["schedule"]["regular_interval"]) + or ( (old_item_state["count"] is not None) and - ( - ((timestamp - old_item_state["timestamp"]) >= check_data["schedule"]["regular_interval"]) - or - ((timestamp - old_item_state["timestamp"]) >= check_data["schedule"]["attentive_interval"]) - ) + ((timestamp - old_item_state["timestamp"]) >= check_data["schedule"]["attentive_interval"]) ) ) if (not due): @@ -233,7 +254,7 @@ def main(): if ( (old_item_state["count"] is not None) and - ((old_item_state["count"] + 1) <= args.threshold) + ((old_item_state["count"] + 1) <= check_data["threshold"]) ) else None ) @@ -247,13 +268,13 @@ def main(): ( (new_item_state["count"] is not None) and - (new_item_state["count"] == args.threshold) + (new_item_state["count"] == check_data["threshold"]) ) or ( (new_item_state["count"] is None) and - args.keep_notifying + check_data["annoy"] ) ): for notification in check_data["notifications"]: diff --git a/source/notification_channels/_interface.py b/source/notification_channels/_interface.py index eaaff41..1df1fb9 100644 --- a/source/notification_channels/_interface.py +++ b/source/notification_channels/_interface.py @@ -1,5 +1,9 @@ class interface_notification_channel(object): + def normalize_conf_node(self, node): + raise NotImplementedError + + def notify(self, parameters, name, data, state, output): raise NotImplementedError diff --git a/source/notification_channels/console.py b/source/notification_channels/console.py index 06722a8..a8c2599 100644 --- a/source/notification_channels/console.py +++ b/source/notification_channels/console.py @@ -1,5 +1,16 @@ class implementation_notification_channel_console(interface_notification_channel): + ''' + [implementation] + ''' + def normalize_conf_node(self, node): + return dict_merge( + { + }, + node + ) + + ''' [implementation] ''' diff --git a/source/notification_channels/email.py b/source/notification_channels/email.py index 94b53bc..2113a71 100644 --- a/source/notification_channels/email.py +++ b/source/notification_channels/email.py @@ -1,5 +1,16 @@ class implementation_notification_channel_email(interface_notification_channel): + ''' + [implementation] + ''' + def normalize_conf_node(self, node): + return dict_merge( + { + }, + node + ) + + ''' [implementation] ''' diff --git a/source/notification_channels/file_touch.py b/source/notification_channels/file_touch.py new file mode 100644 index 0000000..f97151f --- /dev/null +++ b/source/notification_channels/file_touch.py @@ -0,0 +1,19 @@ +class implementation_notification_channel_file_touch(interface_notification_channel): + + ''' + [implementation] + ''' + def normalize_conf_node(self, node): + return dict_merge( + { + }, + node + ) + + + ''' + [implementation] + ''' + def notify(self, parameters, name, data, state, output): + _os.path.touch(parameters["path"]) + diff --git a/source/notification_channels/libnotify.py b/source/notification_channels/libnotify.py new file mode 100644 index 0000000..78ca83c --- /dev/null +++ b/source/notification_channels/libnotify.py @@ -0,0 +1,81 @@ +class implementation_notification_channel_libnotify(interface_notification_channel): + + ''' + [implementation] + ''' + def normalize_conf_node(self, node): + return dict_merge( + { + "icon": None, + }, + node + ) + + + ''' + [implementation] + ''' + def notify(self, parameters, name, data, state, output): + def condition_translate(condition): + if (condition == enum_condition.unknown): + return "normal" + elif (condition == enum_condition.ok): + return "low" + elif (condition == enum_condition.warning): + return "normal" + elif (condition == enum_condition.critical): + return "critical" + else: + raise ValueError("impossible condition") + parts = [] + parts.append( + "notify-send" + ) + ## app name + parts.append( + string_coin( + "--app-name={{app_name}}", + { + "app_name": "heimdall", + } + ) + ) + ## urgency + parts.append( + string_coin( + "--urgency={{urgency}}", + { + "urgency": condition_translate(state["condition"]), + } + ) + ) + ## icon + if ("icon" not in parameters): + pass + else: + parts.append( + string_coin( + "--icon={{icon}}", + { + "icon": parameters["icon"], + } + ) + ) + ## summary + parts.append( + string_coin( + "{{condition}} | {{title}}", + { + "title": data["title"], + "condition": condition_encode(state["condition"]).upper(), + } + ) + ) + ## body + parts.append( + "(no infos)" + if (output == "") else + output + ) + _subprocess.run(parts) + diff --git a/test.hmdl.json b/test.hmdl.json index 24a107a..6079d54 100644 --- a/test.hmdl.json +++ b/test.hmdl.json @@ -3,8 +3,10 @@ }, "checks": { "test": { + "threshold": 1, + "annoy": true, "schedule": { - "regular_interval": 12, + "regular_interval": 60, "attentive_interval": 5 }, "notifications": [ @@ -12,10 +14,19 @@ "kind": "console", "parameters": { } + }, + { + "kind": "libnotify", + "parameters": { + "icon": "/home/fenris/bilder/zeug/heimdall.png" + } } ], - "kind": "test", + "kind": "file_timestamp", "parameters": { + "path": "/tmp/test", + "warning_age": 60, + "critical_age": 120 } } } diff --git a/todo.md b/todo.md index 246cfab..30a5f7c 100644 --- a/todo.md +++ b/todo.md @@ -1,7 +1,10 @@ - parallele Zugriffe auf die Zustands-Datei verhindern - fehlertolerantere Implementierung - Selbst-Test -- Matrix als Benachrichtigungs-Kanal +- Benachrichtigungs-Kanäle: + - Matrix - JSON-Schema für Konfiguration von Programm erzeugen lassen - Möglichkeit dauerhaft laufen zulassen (evtl. als systemd-Dienst) - Versionierung +- Umbenennung: `output` zu `info` +- Test-Routinen diff --git a/tools/build b/tools/build index 525ddb3..5cdf2a3 100755 --- a/tools/build +++ b/tools/build @@ -6,12 +6,14 @@ cat \ source/packages.py \ source/lib.py \ source/check_kinds/_interface.py \ - source/check_kinds/test.py \ source/check_kinds/script.py \ + source/check_kinds/file_timestamp.py \ source/check_kinds/http_request.py \ source/notification_channels/_interface.py \ source/notification_channels/console.py \ + source/notification_channels/file_touch.py \ source/notification_channels/email.py \ + source/notification_channels/libnotify.py \ source/main.py \ >> build/heimdall chmod +x build/heimdall