[mod] build-System [add] localization [mod] check:file_state [mod] check:http_request

This commit is contained in:
Christian Fraß 2022-12-03 16:36:44 +01:00
parent 36412a34af
commit 909314fad7
22 changed files with 1127 additions and 748 deletions

View file

@ -4,8 +4,8 @@
"checks": [ "checks": [
{ {
"name": "test", "name": "test",
"threshold": 1, "threshold": 3,
"annoy": true, "annoy": false,
"schedule": { "schedule": {
"regular_interval": 15, "regular_interval": 15,
"attentive_interval": 5 "attentive_interval": 5
@ -22,11 +22,12 @@
} }
} }
], ],
"kind": "file_timestamp", "kind": "file_state",
"parameters": { "parameters": {
"path": "/tmp/test", "path": "/tmp/test",
"warning_age": 60, "exist": true,
"critical_age": 120 "age_threshold": 60,
"size_threshold": 1
} }
} }
] ]

View file

@ -0,0 +1,25 @@
{
"conditions.unknown": "unbekannt",
"conditions.ok": "in Ordnung",
"conditions.warning": "bedenklich",
"conditions.critical": "kritisch",
"help.title": "Heimdall — Werkzeug zur System-Überwachungs",
"help.args.conf_path": "Pfad zur Konfigurations-Datei",
"help.args.state_path": "Pfad zur Zustands-Datei, welche Daten über vorherige Prüfungen enthält; Standard-Wert: Pfad im temporären Verzeichnis des Systems mit eindeutigem Namen in Bezug auf den Pfad zur Konfigurations-Datei",
"help.args.send_ok_notifications": "ob '{{condition_name}}'-Zustände gemeldet werden sollen",
"help.args.language": "welche Sprache verwendet werden soll (statt der in den Umgebungs-Variablen gesetzten)",
"help.args.erase_state": "ob der Zustand bei Start gelöscht werden soll; das hat zur Folge, dass alle Prüfungen unmittelbar durchgeführt werden",
"help.args.show_schema": "nur das hmdl-JSON-Schema zur Standard-Ausgabe schreiben und beenden",
"help.args.expose_full_conf": "nur die erweiterte Konfiguration zur Standard-Ausgabe schreiben und beenden (nützlich für Fehlersuche)",
"checks.file_state.exists": "Datei existiert (soll aber nicht)",
"checks.file_state.missing": "Datei existiert nicht (soll aber)",
"checks.file_state.timestamp_implausible": "Datei ist scheinbar aus der Zukunft",
"checks.file_state.too_old": "Datei ist zu alt",
"checks.file_state.too_big": "Datei ist zu groß",
"checks.http_request.request_failed": "HTTP-Abfrage fehlgeschlagen",
"checks.http_request.status_code_mismatch": "Status-Code {{status_code_actual}} stimmt nicht mit dem erwarteten Wert {{status_code_expected}} überein",
"checks.http_request.header_value_mismatch": "Header-Wert für Schlüssel '{{key}}' '{{value_actual}}' stimmt nicht mit erwartetem Wert {{value_expected}} überein",
"checks.http_request.body_misses_part": "Rumpf enthält nicht den erwarteten Teil '{{part}}'",
"misc.state_file_path": "Pfad zur Zustands-Datei",
"misc.check_procedure_failed": "Prüfungs-Prozedur fehlgeschlagen"
}

View file

@ -0,0 +1,25 @@
{
"conditions.unknown": "unknown",
"conditions.ok": "ok",
"conditions.warning": "concerning",
"conditions.critical": "critical",
"help.title": "Heimdall — Monitoring Tool",
"help.args.conf_path": "path to the configuration file",
"help.args.state_path": "path to the state file, which contains information about the recent checks; default: file in temporary directory, unique for the conf-path input",
"help.args.send_ok_notifications": "whether an '{{condition_name}}' condition shall be reported",
"help.args.language": "language to use (instead of the language, set in the environment variables)",
"help.args.erase_state": "whether the state shall be deleted on start; this will cause that all checks are executed immediatly",
"help.args.show_schema": "print the hmdl JSON schema to stdout and exit",
"help.args.expose_full_conf": "only print the extended configuration to stdout and exit (useful for debugging)",
"checks.file_state.exists": "file exists (but shall not)",
"checks.file_state.missing": "file does not exist (but shall)",
"checks.file_state.timestamp_implausible": "file is apparently from the future",
"checks.file_state.too_old": "file is too old",
"checks.file_state.too_big": "file is too big",
"checks.http_request.request_failed": "HTTP request failed",
"checks.http_request.status_code_mismatch": "actual status code {{status_code_actual}} does not match expected value {{status_code_expected}}",
"checks.http_request.header_value_mismatch": "actual header value for key '{{key}}' '{{value_actual}}' and does not match the expected value {{value_expected}}",
"checks.http_request.body_misses_part": "body does not contain the expected part '{{part}}'",
"misc.state_file_path": "state file path",
"misc.check_procedure_failed": "check procedure failed"
}

View file

@ -34,8 +34,8 @@ class implementation_notification_channel_console(interface_notification_channel
"[{{title}}] <{{condition}}> {{info}}\n", "[{{title}}] <{{condition}}> {{info}}\n",
{ {
"title": data["title"], "title": data["title"],
"condition": condition_encode(state["condition"]), "condition": condition_show(state["condition"]),
"info": ("(no info)" if (info is None) else info), "info": _json.dumps(info, indent = "\t", ensure_ascii = False),
} }
) )
) )

View file

@ -81,11 +81,7 @@ class implementation_notification_channel_email(interface_notification_channel):
parameters["access"]["password"] parameters["access"]["password"]
) )
message = MIMEText( message = MIMEText(
string_coin( _json.dumps(info, indent = "\t", ensure_ascii = False)
("(no info)" if (info is None) else info),
{
}
)
) )
message["Subject"] = string_coin( message["Subject"] = string_coin(
"{{tags}} {{title}}", "{{tags}} {{title}}",
@ -96,7 +92,7 @@ class implementation_notification_channel_email(interface_notification_channel):
( (
parameters["tags"] parameters["tags"]
+ +
[condition_encode(state["condition"])] [condition_show(state["condition"])]
) )
) )
), ),

View file

@ -34,16 +34,12 @@ class implementation_notification_channel_libnotify(interface_notification_chann
''' '''
def notify(self, parameters, name, data, state, info): def notify(self, parameters, name, data, state, info):
def condition_translate(condition): def condition_translate(condition):
if (condition == enum_condition.unknown): return {
return "normal" enum_condition.unknown: "normal",
elif (condition == enum_condition.ok): enum_condition.ok: "low",
return "low" enum_condition.warning: "normal",
elif (condition == enum_condition.warning): enum_condition.critical: "critical",
return "normal" }[condition]
elif (condition == enum_condition.critical):
return "critical"
else:
raise ValueError("impossible condition")
parts = [] parts = []
parts.append( parts.append(
"notify-send" "notify-send"
@ -84,15 +80,11 @@ class implementation_notification_channel_libnotify(interface_notification_chann
"{{condition}} | {{title}}", "{{condition}} | {{title}}",
{ {
"title": data["title"], "title": data["title"],
"condition": condition_encode(state["condition"]).upper(), "condition": condition_show(state["condition"]).upper(),
} }
) )
) )
## body ## body
parts.append( parts.append(_json.dumps(info, ensure_ascii = False))
"(no info)"
if (info == "") else
info
)
_subprocess.run(parts) _subprocess.run(parts)

View file

@ -8,6 +8,9 @@ class interface_check_kind(object):
raise NotImplementedError raise NotImplementedError
'''
return record<condition:enum_condition,info:any>
'''
def run(self, parameters): def run(self, parameters):
raise NotImplementedError raise NotImplementedError

View file

@ -0,0 +1,174 @@
class implementation_check_kind_file_state(interface_check_kind):
'''
[implementation]
'''
def parameters_schema(self):
'''
- exists (shall, must, must not)
- size (max)
- age (max)
- contains
'''
return {
"type": "object",
"additionalProperties": False,
"properties": {
"path": {
"type": "string"
},
"strict": {
"description": "whether a violation of this check shall be leveled as critical instead of concerning",
"type": "boolean",
"default": True
},
"exist": {
"description": "whether the file is supposed to exist or not",
"type": "boolean",
"default": True
},
"age_threshold": {
"description": "in seconds; ignored if 'exist' is set to false",
"type": ["null", "integer"],
"exclusiveMinimum": 0,
"default": None,
},
"size_threshold": {
"description": "in bytes; ignored if 'exist' is set to false",
"type": "integer",
"exclusiveMinimum": 0,
"default": None,
},
},
"required": [
"path"
]
}
'''
[implementation]
'''
def normalize_conf_node(self, node):
if ("path" not in node):
raise ValueError("missing mandatory field 'path'")
else:
return dict_merge(
{
"strict": True,
"exist": True,
"age_threshold": None,
"size_threshold": None,
},
node
)
'''
[implementation]
'''
def run(self, parameters):
exists = _os.path.exists(parameters["path"])
if (parameters["exist"]):
if (parameters["exist"] and not exists):
return {
"condition": (
enum_condition.critical
if parameters["strict"] else
enum_condition.warning
),
"info": {
"path": parameters["path"],
"faults": [
translation_get("checks.file_state.missing"),
],
"data": {
},
}
}
else:
faults = []
data = {}
stat = _os.stat(parameters["path"])
## age
if True:
if (parameters["age_threshold"] is None):
pass
else:
timestamp_this = get_current_timestamp()
timestamp_that = int(stat.st_atime)
age = (timestamp_this - timestamp_that)
if (age >= 0):
pass
else:
faults.append(translation_get("checks.file_state.timestamp_implausible"))
if (age <= parameters["age_threshold"]):
pass
else:
faults.append(translation_get("checks.file_state.too_old"))
data = dict_merge(
data,
{
"timestamp_of_checking_instance": timestamp_this,
"timestamp_of_file": timestamp_that,
"age_value_in_seconds": age,
"age_threshold_in_seconds": parameters["age_threshold"],
}
)
## size
if True:
if (parameters["size_threshold"] is None):
pass
else:
size = stat.st_size
if (size <= parameters["size_threshold"]):
pass
else:
faults.append(translation_get("checks.file_state.too_big"))
data = dict_merge(
data,
{
"size_value_in_bytes": size,
"size_threshold_in_bytes": parameters["size_threshold_in_bytes"],
}
)
return {
"condition": (
enum_condition.ok
if (len(faults) == 0) else
(
enum_condition.critical
if parameters["strict"] else
enum_condition.warning
)
),
"info": {
"path": parameters["path"],
"faults": faults,
"data": data,
}
}
else:
if (not exists):
return {
"condition": (
enum_condition.critical
if parameters["strict"] else
enum_condition.warning
),
"info": {
"path": parameters["path"],
"faults": [
translation_get("checks.file_state.exists")
],
"data": {
},
}
}
else:
return {
"condition": enum_condition.ok,
"info": {
}
}

View file

@ -75,7 +75,10 @@ class implementation_check_kind_file_timestamp(interface_check_kind):
if (not _os.path.exists(parameters["path"])): if (not _os.path.exists(parameters["path"])):
return { return {
"condition": condition_decode(parameters["condition_on_missing"]), "condition": condition_decode(parameters["condition_on_missing"]),
"info": "file is missing" "info": {
"path": parameters["path"],
"flaw": translation_get("checks.file_timetsamp.missing"),
}
} }
else: else:
result = _os.stat(parameters["path"]) result = _os.stat(parameters["path"])
@ -84,14 +87,12 @@ class implementation_check_kind_file_timestamp(interface_check_kind):
if (age < 0): if (age < 0):
return { return {
"condition": condition_decode(parameters["condition_on_implausible"]), "condition": condition_decode(parameters["condition_on_implausible"]),
"info": string_coin( "info": {
"file is apparently from the future; timestamp of checking instance: {{timestamp_this}}; timestamp of file: {{timestamp_that}} (age in seconds: {{age}})", "path": parameters["path"],
{ "flaw": translation_get("checks.file_timetsamp.implausible"),
"timestamp_this": timestamp, "timestamp_of_checking_instance": timestamp,
"timestamp_that": result.st_atime, "timestamp_of_file": result.st_atime,
"age": ("%u" % age), },
}
),
} }
else: else:
if ((age > 0) and (age <= parameters["warning_age"])): if ((age > 0) and (age <= parameters["warning_age"])):
@ -101,14 +102,13 @@ class implementation_check_kind_file_timestamp(interface_check_kind):
elif (age > parameters["critical_age"]): elif (age > parameters["critical_age"]):
condition = enum_condition.critical condition = enum_condition.critical
else: else:
raise ValueError("impossible state") raise ValueError("impossible")
return { return {
"condition": condition, "condition": condition,
"info": string_coin( "info": {
"age in seconds: {{age}}", "path": parameters["path"],
{ "flaw": translation_get("checks.file_timetsamp.too_old"),
"age": ("%u" % age), "age_in_seconds": ("%u" % age),
} },
),
} }

View file

@ -59,11 +59,11 @@ class implementation_check_kind_http_request(interface_check_kind):
"required": [ "required": [
] ]
}, },
"as_warning": { "strict": {
"description": "whether a violation of this check shall be exposed as warning instead of critical; default: false", "description": "whether a violation of this check shall be leveled as critical instead of concerning",
"type": "boolean", "type": "boolean",
"default": False "default": True
} },
}, },
"required": [ "required": [
"request" "request"
@ -75,7 +75,7 @@ class implementation_check_kind_http_request(interface_check_kind):
[implementation] [implementation]
''' '''
def normalize_conf_node(self, node): def normalize_conf_node(self, node):
return dict_merge( node_ = dict_merge(
{ {
"request": { "request": {
"method": "GET" "method": "GET"
@ -83,11 +83,16 @@ class implementation_check_kind_http_request(interface_check_kind):
"response": { "response": {
"status_code": 200 "status_code": 200
}, },
"as_warning": False, "strict": True,
}, },
node, node,
True True
) )
allowed_methods = set(["GET", "POST"])
if (node_["request"]["method"] not in allowed_methods):
raise ValueError("invalid HTTP request method: %s" % node_["request"]["method"])
else:
return node_
''' '''
@ -95,7 +100,6 @@ class implementation_check_kind_http_request(interface_check_kind):
''' '''
def run(self, parameters): def run(self, parameters):
if (parameters["request"]["method"] == "GET"): if (parameters["request"]["method"] == "GET"):
method_handled = True
try: try:
response = _requests.get( response = _requests.get(
parameters["request"]["target"] parameters["request"]["target"]
@ -105,7 +109,6 @@ class implementation_check_kind_http_request(interface_check_kind):
error = error_ error = error_
response = None response = None
elif (parameters["request"]["method"] == "POST"): elif (parameters["request"]["method"] == "POST"):
method_handled = True
try: try:
response = _requests.post( response = _requests.post(
parameters["request"]["target"] parameters["request"]["target"]
@ -115,78 +118,84 @@ class implementation_check_kind_http_request(interface_check_kind):
error = error_ error = error_
response = None response = None
else: else:
method_handled = False raise ValueError("impossible")
response = None faults = []
if (not method_handled): if (response is None):
return { return {
"condition": enum_condition.unknown, "condition": (
"info": ("invalid HTTP request method: %s" % parameters["request"]["method"]) enum_condition.critical
if parameters["strict"] else
enum_condition.warning
),
"info": {
"request": parameters["request"],
"faults": [
faults.append(translation_get("checks.http_request.request_failed")),
],
}
} }
else: else:
if (response is None): for (key, value, ) in parameters["response"].items():
return { if (key == "status_code"):
"condition": ( if ((value is None) or (response.status_code == value)):
enum_condition.warning pass
if parameters["as_warning"] else
enum_condition.critical
),
"info": "HTTP request failed",
}
else:
lines = []
for (key, value, ) in parameters["response"].items():
if (key == "status_code"):
if ((value is None) or (response.status_code == value)):
pass
else:
lines.append(
string_coin(
"actual status code {{status_code_actual}} does not match expected value {{status_code_expected}}",
{
"status_code_actual": ("%u" % response.status_code),
"status_code_expected": ("%u" % value),
}
)
)
elif (key == "headers"):
for (header_key, header_value, ) in value.items():
if (response.headers[header_key] == header_value):
pass
else:
lines.append(
string_coin(
"actual header value for key {{key}} is {{value_actual}} and does not match the expected value {{value_expected}}",
{
"key": header_key,
"value_actual": response.headers[header_key],
"value_expected": header_value,
}
)
)
elif (key == "body_part"):
if (response.text.find(value) >= 0):
pass
else:
lines.append(
string_coin(
"body does not contain the expected part '{{part}}'",
{
"part": value,
}
)
)
else: else:
raise ValueError("unhandled ") faults.append(
return { translation_get(
"condition": ( "checks.http_request.status_code_mismatch",
enum_condition.ok {
if (len(lines) <= 0) else "status_code_actual": ("%u" % response.status_code),
( "status_code_expected": ("%u" % value),
enum_condition.warning }
if parameters["as_warning"] else )
enum_condition.critical
) )
), elif (key == "headers"):
"info": "\n".join(lines), for (header_key, header_value, ) in value.items():
if (response.headers[header_key] == header_value):
pass
else:
faults.append(
translation_get(
"checks.http_request.header_value_mismatch",
{
"key": header_key,
"value_actual": response.headers[header_key],
"value_expected": header_value,
}
)
)
elif (key == "body_part"):
if (response.text.find(value) >= 0):
pass
else:
faults.append(
translation_get(
"checks.http_request.body_misses_part",
{
"part": value,
}
)
)
else:
raise ValueError("unhandled")
return {
"condition": (
enum_condition.ok
if (len(faults) <= 0) else
(
enum_condition.critical
if parameters["strict"] else
enum_condition.warning
)
),
"info": {
"request": parameters["request"],
"response": {
"status_code": response.status_code,
# "headers": dict(map(lambda pair: pair, response.headers.items())),
# "body": response.text,
},
"faults": faults,
} }
}

View file

@ -52,9 +52,13 @@ class implementation_check_kind_script(interface_check_kind):
elif (result.returncode == 3): elif (result.returncode == 3):
condition = enum_condition.critical condition = enum_condition.critical
else: else:
raise ValueError("invalid exit code: %i" % result.returncode) # raise ValueError("invalid exit code: %i" % result.returncode)
condition = enum_condition.unknown
return { return {
"condition": condition, "condition": condition,
"info": result.stdout.decode(), "info": {
"stdout": result.stdout.decode(),
"stderr": result.stderr.decode(),
},
} }

38
source/logic/condition.py Normal file
View file

@ -0,0 +1,38 @@
class enum_condition(_enum.Enum):
unknown = 0
ok = 1
warning = 2
critical = 3
'''
converts a condition to a human readable string
'''
def condition_show(condition):
return translation_get(
{
enum_condition.unknown: "conditions.unknown",
enum_condition.ok: "conditions.ok",
enum_condition.warning: "conditions.warning",
enum_condition.critical: "conditions.critical",
}[condition]
)
def condition_encode(condition):
return {
enum_condition.unknown: "unknown",
enum_condition.ok: "ok",
enum_condition.warning: "warning",
enum_condition.critical: "critical",
}[condition]
def condition_decode(condition_encoded):
return {
"unknown": enum_condition.unknown,
"ok": enum_condition.ok,
"warning": enum_condition.warning,
"critical": enum_condition.critical,
}[condition_encoded]

317
source/logic/conf.py Normal file
View file

@ -0,0 +1,317 @@
def conf_schema_active():
return {
"type": "boolean",
"default": True
}
def conf_schema_threshold():
return {
"description": "how often a condition has to occur in order to be reported",
"type": "integer",
"minimum": 1,
"default": 3
}
def conf_schema_annoy():
return {
"description": "whether notifications shall be kept sending after the threshold has been surpassed",
"type": "boolean",
"default": False
}
def conf_schema_interval(default):
return {
"description": "in seconds or as text",
"anyOf": [
{
"type": "integer",
"exclusiveMinimum": 0
},
{
"type": "string",
"enum": [
"minute",
"hour",
"day",
"week",
]
},
],
"default": default,
}
def conf_schema_schedule():
return {
"type": "object",
"additionalProperties": False,
"properties": {
"regular_interval": conf_schema_interval(60 * 60),
"attentive_interval": conf_schema_interval(60 * 2),
},
"required": [
]
}
def conf_schema_notifications(notification_channel_implementations):
return {
"type": "array",
"items": {
"anyOf": list(
map(
lambda pair: {
"title": ("check kind '%s'" % pair[0]),
"type": "object",
"unevaluatedProperties": False,
"properties": {
"kind": {
"type": "string",
"enum": [pair[0]]
},
"parameters": pair[1].parameters_schema(),
},
"required": [
"kind",
"parameters"
]
},
notification_channel_implementations.items()
)
)
},
"default": [
{
"kind": "console",
"parameters": {
}
}
]
}
def conf_schema_root(check_kind_implementations, notification_channel_implementations):
return {
"type": "object",
"additionalProperties": False,
"properties": {
"defaults": {
"description": "default values for checks",
"type": "object",
"additionalProperties": False,
"properties": {
"active": conf_schema_active(),
"threshold": conf_schema_threshold(),
"annoy": conf_schema_annoy(),
"schedule": conf_schema_schedule(),
"notifications": conf_schema_notifications(notification_channel_implementations),
},
"required": [
]
},
"checks": {
"type": "array",
"items": {
"allOf": [
{
"description": "should represent a specific check",
"type": "object",
"unevaluatedProperties": False,
"properties": {
"name": {
"type": "string"
},
"title": {
"type": "string"
},
"active": conf_schema_active(),
"threshold": conf_schema_threshold(),
"annoy": conf_schema_annoy(),
"schedule": conf_schema_schedule(),
"notifications": conf_schema_notifications(notification_channel_implementations),
},
"required": [
"name",
]
},
{
"anyOf": list(
map(
lambda pair: {
"title": ("notification channel '%s'" % pair[0]),
"type": "object",
"unevaluatedProperties": False,
"properties": {
"kind": {
"type": "string",
"enum": [pair[0]]
},
"parameters": pair[1].parameters_schema(),
},
"required": [
"kind",
"parameters"
]
},
check_kind_implementations.items()
)
),
},
]
}
}
},
"required": [
"defaults",
"checks",
]
}
def conf_normalize_interval(interval_raw):
if (type(interval_raw) == int):
return interval_raw
elif (type(interval_raw) == str):
map_ = {
"minute": (60),
"hour": (60 * 60),
"day": (60 * 60 * 24),
"week": (60 * 60 * 24 * 7),
}
if (interval_raw not in map_):
raise ValueError("invalid string interval value: %s" % interval_raw)
else:
return map_[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_notification(notification_channel_implementations, node):
if (node["kind"] not in notification_channel_implementations):
raise ValueError("invalid notification kind: %s" % notification["kind"])
else:
return {
"kind": node["kind"],
"parameters": notification_channel_implementations[node["kind"]].normalize_conf_node(node["parameters"]),
}
def conf_normalize_defaults(notification_channel_implementations, node):
node_ = dict_merge(
{
"active": True,
"threshold": 3,
"annoy": False,
"schedule": {
"regular_interval": (60 * 60),
"attentive_interval": (60 * 2),
},
"notifications": [
],
},
node
)
return {
"active": node_["active"],
"threshold": node_["threshold"],
"annoy": node_["annoy"],
"schedule": node_["schedule"],
"notifications": list(
map(
lambda x: conf_normalize_notification(notification_channel_implementations, x),
node_["notifications"]
)
),
}
def conf_normalize_check(check_kind_implementations, notification_channel_implementations, defaults, node):
if ("name" not in node):
raise ValueError("missing mandatory field in 'check' node: 'name'")
else:
if ("kind" not in node):
raise ValueError("missing mandatory field in 'check' node: 'kind'")
else:
if (node["kind"] not in check_kind_implementations):
raise ValueError("invalid check kind: '%s'" % node["kind"])
else:
node_ = dict_merge(
{
"title": node["name"],
"active": defaults["active"],
"threshold": defaults["threshold"],
"annoy": defaults["annoy"],
"schedule": defaults["schedule"],
"notifications": defaults["notifications"],
"parameters": {},
},
node
)
return {
"name": node_["name"],
"title": node_["title"],
"active": node_["active"],
"threshold": node_["threshold"],
"annoy": node_["annoy"],
"schedule": conf_normalize_schedule(node_["schedule"]),
"notifications": list(
map(
lambda x: conf_normalize_notification(notification_channel_implementations, x),
node_["notifications"]
)
),
"kind": node_["kind"],
"parameters": check_kind_implementations[node_["kind"]].normalize_conf_node(node_["parameters"]),
}
def conf_normalize_root(check_kind_implementations, notification_channel_implementations, node):
counts = {}
for node_ in node["checks"]:
if (node_["name"] not in counts):
counts[node_["name"]] = 0
counts[node_["name"]] += 1
fails = list(filter(lambda pair: (pair[1] > 1), counts.items()))
if (len(fails) > 0):
raise ValueError(
string_coin(
"ambiguous check names: {{names}}",
{
"names": ",".join(counts.keys()),
}
)
)
else:
defaults = conf_normalize_defaults(notification_channel_implementations, node["defaults"])
return {
"defaults": defaults,
"checks": list(
map(
lambda node_: conf_normalize_check(
check_kind_implementations,
notification_channel_implementations,
defaults,
node_
),
node["checks"]
)
)
}

View file

@ -1,7 +1,3 @@
import enum as _enum
import time as _time
def file_read(path): def file_read(path):
handle = open(path, "r") handle = open(path, "r")
content = handle.read() content = handle.read()
@ -42,36 +38,9 @@ def dict_merge(core_dict, mantle_dict, recursive = False):
return result_dict return result_dict
class enum_condition(_enum.Enum): def env_get_language():
unknown = 0 env_lang = _os.environ.get("LANG")
ok = 1 locale = env_lang.split(".")[0]
warning = 2 language = locale.split("_")[0]
critical = 3 return language
def condition_encode(condition):
if (condition == enum_condition.ok):
return "ok"
elif (condition == enum_condition.unknown):
return "unknown"
elif (condition == enum_condition.warning):
return "warning"
elif (condition == enum_condition.critical):
return "critical"
else:
raise ValueError("unhandled condition: %s" % str(condition))
def condition_decode(condition_encoded):
if (condition_encoded == "ok"):
return enum_condition.ok
elif (condition_encoded == "unknown"):
return enum_condition.unknown
elif (condition_encoded == "warning"):
return enum_condition.warning
elif (condition_encoded == "critical"):
return enum_condition.critical
else:
raise ValueError("unhandled encoded condition: %s" % condition_encoded)

View file

@ -0,0 +1,46 @@
translation_language_fallback = None
translation_language_shall = None
def translation_initialize(language_fallback, language_shall):
global translation_language_fallback
global translation_language_shall
translation_language_fallback = language_fallback
translation_language_shall = language_shall
def translation_get(key, arguments = None):
global translation_language_fallback
global translation_language_shall
global localization_data
if (arguments is None):
arguments = {}
for language in [translation_language_shall, translation_language_fallback]:
if (language not in localization_data):
_sys.stderr.write(
string_coin(
"[notice] missing localization data for language '{{language}}'\n",
{
"language": language,
}
)
)
else:
if (key not in localization_data[language]):
_sys.stderr.write(
string_coin(
"[notice] missing translation in language '{{language}}' for key '{{key}}'\n",
{
"language": language,
"key": key,
}
)
)
else:
return string_coin(
localization_data[language][key],
arguments
)
return ("{{%s}}" % key)

268
source/logic/main.py Normal file
View file

@ -0,0 +1,268 @@
def state_encode(state):
return {
"timestamp": state["timestamp"],
"condition": condition_encode(state["condition"]),
"count": state["count"],
}
def state_decode(state_encoded):
return {
"timestamp": state_encoded["timestamp"],
"condition": condition_decode(state_encoded["condition"]),
"count": state_encoded["count"],
}
def main():
## setup translation for the first time
translation_initialize("en", env_get_language())
## args
argumentparser = _argparse.ArgumentParser(
description = translation_get("help.title"),
formatter_class = _argparse.ArgumentDefaultsHelpFormatter
)
argumentparser.add_argument(
"-c",
"--conf-path",
type = str,
default = "monitoring.hmdl.json",
dest = "conf_path",
metavar = "<conf-path>",
help = translation_get("help.args.conf_path"),
)
argumentparser.add_argument(
"-f",
"--state-path",
type = str,
default = None,
dest = "state_path",
metavar = "<state-path>",
help = translation_get("help.args.state_path"),
)
argumentparser.add_argument(
"-y",
"--send-ok-notifications",
action = "store_true",
default = False,
dest = "send_ok_notifications",
help = translation_get("help.args.send_ok_notifications", {"condition_name": translation_get("conditions.ok")}),
)
argumentparser.add_argument(
"-l",
"--language",
type = str,
choices = localization_data.keys(),
default = None,
dest = "language",
metavar = "<language>",
help = translation_get("help.args.language"),
)
argumentparser.add_argument(
"-x",
"--erase-state",
action = "store_true",
default = False,
dest = "erase_state",
help = translation_get("help.args.erase_state"),
)
argumentparser.add_argument(
"-s",
"--show-schema",
action = "store_true",
default = False,
dest = "show_schema",
help = translation_get("help.args.show_schema"),
)
argumentparser.add_argument(
"-e",
"--expose-full-conf",
action = "store_true",
default = False,
dest = "expose_full_conf",
help = translation_get("help.args.expose_full_conf"),
)
args = argumentparser.parse_args()
## vars
id_ = _hashlib.sha256(_os.path.abspath(args.conf_path).encode("ascii")).hexdigest()[:8]
state_path = (
args.state_path
if (args.state_path is not None) else
_os.path.join(
_tempfile.gettempdir(),
string_coin("monitoring-state-{{id}}.json", {"id": id_})
)
)
## exec
### setup translation for the second time
if (args.language is not None):
translation_initialize("en", args.language)
### load check kind implementations
check_kind_implementations = {
"script": implementation_check_kind_script(),
"file_state": implementation_check_kind_file_state(),
"http_request": implementation_check_kind_http_request(),
}
### load notification channel implementations
notification_channel_implementations = {
"console": implementation_notification_channel_console(),
"email": implementation_notification_channel_email(),
"libnotify": implementation_notification_channel_libnotify(),
}
if (args.show_schema):
_sys.stdout.write(
_json.dumps(
conf_schema_root(
check_kind_implementations,
notification_channel_implementations
),
indent = "\t"
)
+
"\n"
)
else:
_sys.stderr.write(
string_coin(
"[info] {{label}}: {{path}}\n",
{
"label": translation_get("misc.state_file_path"),
"path": state_path,
}
)
)
### get configuration data
conf = conf_normalize_root(
check_kind_implementations,
notification_channel_implementations,
_json.loads(file_read(args.conf_path))
)
if (args.expose_full_conf):
_sys.stdout.write(_json.dumps(checks, indent = "\t") + "\n")
_sys.exit(1)
else:
### get state data
if (
(not _os.path.exists(state_path))
or
args.erase_state
):
state_data = {}
file_write(state_path, _json.dumps(state_data, indent = "\t"))
else:
state_data = _json.loads(file_read(state_path))
### iterate through checks
for check_data in conf["checks"]:
if (not check_data["active"]):
pass
else:
### get old state and examine whether the check shall be executed
old_item_state = (
None
if (check_data["name"] not in state_data) else
state_decode(state_data[check_data["name"]])
)
timestamp = get_current_timestamp()
due = (
(old_item_state is None)
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"]["attentive_interval"])
)
)
if (not due):
pass
else:
_sys.stderr.write(
string_coin(
"-- {{check_name}}\n",
{
"check_name": check_data["name"],
}
)
)
### execute check and set new state
try:
result = check_kind_implementations[check_data["kind"]].run(check_data["parameters"])
except Exception as error:
result = {
"condition": enum_condition.unknown,
"info": {
# "cause": translation_get("misc.check_procedure_failed"),
"error": str(error),
},
}
new_item_state = {
"timestamp": timestamp,
"condition": result["condition"],
"count": (
1
if (
(old_item_state is None)
or
(old_item_state["condition"] != result["condition"])
) else
(
(old_item_state["count"] + 1)
if (
(old_item_state["count"] is not None)
and
((old_item_state["count"] + 1) <= check_data["threshold"])
) else
None
)
),
}
state_data[check_data["name"]] = state_encode(new_item_state)
file_write(state_path, _json.dumps(state_data, indent = "\t"))
### send notifications
if (
(
(
(new_item_state["count"] is not None)
and
(new_item_state["count"] == check_data["threshold"])
)
or
(
(new_item_state["count"] is None)
and
check_data["annoy"]
)
)
and
(
(new_item_state["condition"] != enum_condition.ok)
or
args.send_ok_notifications
)
):
for notification in check_data["notifications"]:
notification_channel_implementations[notification["kind"]].notify(
notification["parameters"],
check_data["name"],
check_data,
new_item_state,
result["info"]
)
main()

View file

@ -6,5 +6,7 @@ import tempfile as _tempfile
import argparse as _argparse import argparse as _argparse
import json as _json import json as _json
import requests as _requests import requests as _requests
import enum as _enum
import time as _time
import smtplib as _smtplib import smtplib as _smtplib
from email.mime.text import MIMEText from email.mime.text import MIMEText

View file

@ -1,533 +0,0 @@
def state_encode(state):
return {
"timestamp": state["timestamp"],
"condition": condition_encode(state["condition"]),
"count": state["count"],
}
def state_decode(state_encoded):
return {
"timestamp": state_encoded["timestamp"],
"condition": condition_decode(state_encoded["condition"]),
"count": state_encoded["count"],
}
def schema_active():
return {
"type": "boolean",
"default": True
}
def schema_threshold():
return {
"description": "how often a condition has to occur in order to be reported",
"type": "integer",
"minimum": 1,
"default": 3
}
def schema_annoy():
return {
"description": "whether notifications shall be kept sending after the threshold has been surpassed",
"type": "boolean",
"default": False
}
def schema_interval(default):
return {
"description": "in seconds or as text",
"anyOf": [
{
"type": "integer",
"exclusiveMinimum": 0
},
{
"type": "string",
"enum": [
"minute",
"hour",
"day",
"week",
]
},
],
"default": default,
}
def schema_schedule():
return {
"type": "object",
"additionalProperties": False,
"properties": {
"regular_interval": schema_interval(60 * 60),
"attentive_interval": schema_interval(60 * 2),
},
"required": [
]
}
def schema_notifications(notification_channel_implementations):
return {
"type": "array",
"items": {
"anyOf": list(
map(
lambda pair: {
"title": ("check kind '%s'" % pair[0]),
"type": "object",
"unevaluatedProperties": False,
"properties": {
"kind": {
"type": "string",
"enum": [pair[0]]
},
"parameters": pair[1].parameters_schema(),
},
"required": [
"kind",
"parameters"
]
},
notification_channel_implementations.items()
)
)
},
"default": [
{
"kind": "console",
"parameters": {
}
}
]
}
def schema_root(check_kind_implementations, notification_channel_implementations):
return {
"type": "object",
"additionalProperties": False,
"properties": {
"defaults": {
"description": "default values for checks",
"type": "object",
"additionalProperties": False,
"properties": {
"active": schema_active(),
"threshold": schema_threshold(),
"annoy": schema_annoy(),
"schedule": schema_schedule(),
"notifications": schema_notifications(notification_channel_implementations),
},
"required": [
]
},
"checks": {
"type": "array",
"items": {
"allOf": [
{
"description": "should represent a specific check",
"type": "object",
"unevaluatedProperties": False,
"properties": {
"name": {
"type": "string"
},
"title": {
"type": "string"
},
"active": schema_active(),
"threshold": schema_threshold(),
"annoy": schema_annoy(),
"schedule": schema_schedule(),
"notifications": schema_notifications(notification_channel_implementations),
},
"required": [
"name",
]
},
{
"anyOf": list(
map(
lambda pair: {
"title": ("notification channel '%s'" % pair[0]),
"type": "object",
"unevaluatedProperties": False,
"properties": {
"kind": {
"type": "string",
"enum": [pair[0]]
},
"parameters": pair[1].parameters_schema(),
},
"required": [
"kind",
"parameters"
]
},
check_kind_implementations.items()
)
),
},
]
}
}
},
"required": [
"defaults",
"checks",
]
}
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_notification(notification_channel_implementations, node):
return {
"kind": node["kind"],
"parameters": notification_channel_implementations[node["kind"]].normalize_conf_node(node["parameters"]),
}
def conf_normalize_defaults(notification_channel_implementations, node):
node_ = dict_merge(
{
"active": True,
"threshold": 3,
"annoy": False,
"schedule": {
"regular_interval": (60 * 60),
"attentive_interval": (60 * 2),
},
"notifications": [
],
},
node
)
return {
"active": node_["active"],
"threshold": node_["threshold"],
"annoy": node_["annoy"],
"schedule": node_["schedule"],
"notifications": list(
map(
lambda x: conf_normalize_notification(notification_channel_implementations, x),
node_["notifications"]
)
),
}
def conf_normalize_check(check_kind_implementations, notification_channel_implementations, defaults, node):
if ("name" not in node):
raise ValueError("missing mandatory 'check' field 'name'")
else:
if ("kind" not in node):
raise ValueError("missing mandatory 'check' field 'kind'")
else:
if (node["kind"] not in check_kind_implementations):
raise ValueError("unhandled kind: %s" % node["kind"])
else:
node_ = dict_merge(
{
"title": node["name"],
"active": defaults["active"],
"threshold": defaults["threshold"],
"annoy": defaults["annoy"],
"schedule": defaults["schedule"],
"notifications": defaults["notifications"],
"parameters": {},
},
node
)
return {
"name": node_["name"],
"title": node_["title"],
"active": node_["active"],
"threshold": node_["threshold"],
"annoy": node_["annoy"],
"schedule": conf_normalize_schedule(node_["schedule"]),
"notifications": list(
map(
lambda x: conf_normalize_notification(notification_channel_implementations, x),
node_["notifications"]
)
),
"kind": node_["kind"],
"parameters": check_kind_implementations[node_["kind"]].normalize_conf_node(node_["parameters"]),
}
def conf_normalize_root(check_kind_implementations, notification_channel_implementations, node):
counts = {}
for node_ in node["checks"]:
if (node_["name"] not in counts):
counts[node_["name"]] = 0
counts[node_["name"]] += 1
fails = list(filter(lambda pair: (pair[1] > 1), counts.items()))
if (len(fails) > 0):
raise ValueError(
string_coin(
"ambiguous check names: {{names}}",
{
"names": ",".join(counts.keys()),
}
)
)
else:
return list(
map(
lambda node_: conf_normalize_check(
check_kind_implementations,
notification_channel_implementations,
conf_normalize_defaults(notification_channel_implementations, node["defaults"]),
node_
),
node["checks"]
)
)
def main():
## args
argumentparser = _argparse.ArgumentParser(
description = "Heimdall-Monitoring-Tool",
formatter_class = _argparse.ArgumentDefaultsHelpFormatter
)
argumentparser.add_argument(
"-c",
"--conf-path",
type = str,
default = "monitoring.hmdl.json",
dest = "conf_path",
metavar = "<conf-path>",
help = "path to the configuration file"
)
argumentparser.add_argument(
"-f",
"--state-path",
type = str,
default = None,
dest = "state_path",
metavar = "<state-path>",
help = "path to the state file, which contains information about the recent checks; default: file in temporary directory, unique for the conf-path input"
)
argumentparser.add_argument(
"-x",
"--erase-state",
action = "store_true",
default = False,
dest = "erase_state",
help = "whether the state shall be deleted on start; this will cause that all checks are executed"
)
argumentparser.add_argument(
"-s",
"--show-schema",
action = "store_true",
default = False,
dest = "show_schema",
help = "print the hmdl JSON schema to stdout and exit"
)
argumentparser.add_argument(
"-e",
"--expose-full-conf",
action = "store_true",
default = False,
dest = "expose_full_conf",
help = "only print the extended configuration to stdout and exit (useful for debug purposes)"
)
args = argumentparser.parse_args()
## vars
id_ = _hashlib.sha256(_os.path.abspath(args.conf_path).encode("ascii")).hexdigest()[:8]
state_path = (
args.state_path
if (args.state_path is not None) else
_os.path.join(
_tempfile.gettempdir(),
string_coin("monitoring-state-{{id}}.json", {"id": id_})
)
)
## exec
### load check kind implementations
check_kind_implementations = {
"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(),
}
if (args.show_schema):
_sys.stdout.write(
_json.dumps(
schema_root(
check_kind_implementations,
notification_channel_implementations
),
indent = "\t"
)
+
"\n"
)
else:
_sys.stderr.write(">> state file path: %s\n" % state_path)
### get configuration data
checks = conf_normalize_root(
check_kind_implementations,
notification_channel_implementations,
_json.loads(file_read(args.conf_path))
)
if (args.expose_full_conf):
_sys.stdout.write(_json.dumps(checks, indent = "\t") + "\n")
_sys.exit(1)
else:
### get state data
if (
(not _os.path.exists(state_path))
or
args.erase_state
):
state_data = {}
file_write(state_path, _json.dumps(state_data, indent = "\t"))
else:
state_data = _json.loads(file_read(state_path))
### iterate through checks
for check_data in checks:
if (not check_data["active"]):
pass
else:
### get old state and examine whether the check shall be executed
old_item_state = (
None
if (check_data["name"] not in state_data) else
state_decode(state_data[check_data["name"]])
)
timestamp = get_current_timestamp()
due = (
(old_item_state is None)
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"]["attentive_interval"])
)
)
if (not due):
pass
else:
_sys.stderr.write(
string_coin(
"-- {{check_name}}\n",
{
"check_name": check_data["name"],
}
)
)
### execute check and set new state
result = check_kind_implementations[check_data["kind"]].run(check_data["parameters"])
new_item_state = {
"timestamp": timestamp,
"condition": result["condition"],
"count": (
1
if (
(old_item_state is None)
or
(old_item_state["condition"] != result["condition"])
) else
(
(old_item_state["count"] + 1)
if (
(old_item_state["count"] is not None)
and
((old_item_state["count"] + 1) <= check_data["threshold"])
) else
None
)
),
}
state_data[check_data["name"]] = state_encode(new_item_state)
file_write(state_path, _json.dumps(state_data, indent = "\t"))
### send notifications
if (
(
(new_item_state["count"] is not None)
and
(new_item_state["count"] == check_data["threshold"])
)
or
(
(new_item_state["count"] is None)
and
check_data["annoy"]
)
):
for notification in check_data["notifications"]:
if (notification["kind"] in notification_channel_implementations):
notification_channel_implementations[notification["kind"]].notify(
notification["parameters"],
check_data["name"],
check_data,
new_item_state,
result["info"]
)
else:
raise ValueError("invalid notification kind: %s" % notification["kind"])
main()

View file

@ -1,37 +0,0 @@
class implementation_notification_channel_file_touch(interface_notification_channel):
'''
[implementation]
'''
def parameters_schema(self):
return {
"type": "object",
"additionalProperties": False,
"properties": {
"path": {
"type": "string"
}
},
"required": [
"path"
]
}
'''
[implementation]
'''
def normalize_conf_node(self, node):
return dict_merge(
{
},
node
)
'''
[implementation]
'''
def notify(self, parameters, name, data, state, info):
_os.path.touch(parameters["path"])

View file

@ -6,4 +6,4 @@
- Möglichkeit dauerhaft laufen zulassen (evtl. als systemd-Dienst) - Möglichkeit dauerhaft laufen zulassen (evtl. als systemd-Dienst)
- Versionierung - Versionierung
- Test-Routinen - Test-Routinen
- neu schreiben in TypeScript (und plankton dafür nutzen)?

View file

@ -1,19 +1,99 @@
#!/usr/bin/env sh #!/usr/bin/env python3
mkdir -p build import sys as _sys
echo "#!/usr/bin/env python3" > build/heimdall import os as _os
cat \ import json as _json
source/packages.py \ import stat as _stat
source/lib.py \
source/check_kinds/_interface.py \
source/check_kinds/script.py \ def file_read(path):
source/check_kinds/file_timestamp.py \ handle = open(path, "r")
source/check_kinds/http_request.py \ content = handle.read()
source/notification_channels/_interface.py \ handle.close()
source/notification_channels/console.py \ return content
source/notification_channels/file_touch.py \
source/notification_channels/email.py \
source/notification_channels/libnotify.py \ def file_write(path, content):
source/main.py \ handle = open(path, "w")
>> build/heimdall handle.write(content)
chmod +x build/heimdall handle.close()
def string_coin(template, arguments):
result = template
for (key, value, ) in arguments.items():
result = result.replace("{{%s}}" % key, value)
return result
def python_data_encode(data):
return _json.dumps(data, indent = "\t")
def main():
## consts
dir_source = "source"
dir_build = "build"
sources_logic = [
_os.path.join(dir_source, "logic", "packages.py"),
_os.path.join(dir_source, "logic", "lib.py"),
_os.path.join(dir_source, "logic", "localization.py"),
_os.path.join(dir_source, "logic", "condition.py"),
_os.path.join(dir_source, "logic", "conf.py"),
_os.path.join(dir_source, "logic", "checks", "_interface.py"),
_os.path.join(dir_source, "logic", "checks", "script.py"),
_os.path.join(dir_source, "logic", "checks", "file_state.py"),
_os.path.join(dir_source, "logic", "checks", "http_request.py"),
_os.path.join(dir_source, "logic", "channels", "_interface.py"),
_os.path.join(dir_source, "logic", "channels", "console.py"),
_os.path.join(dir_source, "logic", "channels", "email.py"),
_os.path.join(dir_source, "logic", "channels", "libnotify.py"),
_os.path.join(dir_source, "logic", "main.py"),
]
path_compilation = _os.path.join(dir_build, "heimdall")
## exec
if (not _os.path.exists(dir_build)):
_os.mkdir(dir_build)
compilation = ""
compilation += "#!/usr/bin/env python3\n\n"
### localization
if True:
localization_data = dict(
map(
lambda entry: (
entry.name.split(".")[0],
_json.loads(file_read(entry.path)),
),
filter(
lambda entry: (entry.is_file() and entry.name.endswith(".json")),
_os.scandir(_os.path.join(dir_source, "localization"))
)
)
)
compilation += string_coin(
"localization_data = {{data}}\n\n",
{
"data": python_data_encode(localization_data),
}
)
### logic
for path in sources_logic:
compilation += (file_read(path) + "\n")
### write to file
if _os.path.exists(path_compilation):
_os.remove(path_compilation)
file_write(path_compilation, compilation)
### postproess
_os.chmod(
path_compilation,
(_stat.S_IRWXU | _stat.S_IXGRP | _stat.S_IXOTH)
)
main()