From 82b47ffa14c55daac33900c65f2e1f00bfae3a7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Fra=C3=9F?= Date: Tue, 29 Nov 2022 23:53:14 +0100 Subject: [PATCH] [ini] --- .gitignore | 3 + hmdl.schema.json | 299 ++++++++++++++++++ readme.md | 56 ++++ ...on_check_kind_http_request.cpython-310.pyc | Bin 0 -> 2424 bytes ...entation_check_kind_script.cpython-310.pyc | Bin 0 -> 1227 bytes ...tification_channel_console.cpython-310.pyc | Bin 0 -> 900 bytes ...notification_channel_email.cpython-310.pyc | Bin 0 -> 1440 bytes .../interface_check_kind.cpython-310.pyc | Bin 0 -> 652 bytes ...rface_notification_channel.cpython-310.pyc | Bin 0 -> 560 bytes source/__pycache__/lib.cpython-310.pyc | Bin 0 -> 2118 bytes source/heimdall.py | 270 ++++++++++++++++ .../implementation_check_kind_http_request.py | 120 +++++++ source/implementation_check_kind_script.py | 37 +++ ...ementation_notification_channel_console.py | 16 + ...plementation_notification_channel_email.py | 44 +++ source/interface_check_kind.py | 9 + source/interface_notification_channel.py | 5 + source/lib.py | 77 +++++ source/main.py | 256 +++++++++++++++ source/packages.py | 9 + todo.md | 4 + tools/build | 16 + tools/install | 3 + 23 files changed, 1224 insertions(+) create mode 100644 .gitignore create mode 100644 hmdl.schema.json create mode 100644 readme.md create mode 100644 source/__pycache__/implementation_check_kind_http_request.cpython-310.pyc create mode 100644 source/__pycache__/implementation_check_kind_script.cpython-310.pyc create mode 100644 source/__pycache__/implementation_notification_channel_console.cpython-310.pyc create mode 100644 source/__pycache__/implementation_notification_channel_email.cpython-310.pyc create mode 100644 source/__pycache__/interface_check_kind.cpython-310.pyc create mode 100644 source/__pycache__/interface_notification_channel.cpython-310.pyc create mode 100644 source/__pycache__/lib.cpython-310.pyc create mode 100755 source/heimdall.py create mode 100644 source/implementation_check_kind_http_request.py create mode 100644 source/implementation_check_kind_script.py create mode 100644 source/implementation_notification_channel_console.py create mode 100644 source/implementation_notification_channel_email.py create mode 100644 source/interface_check_kind.py create mode 100644 source/interface_notification_channel.py create mode 100644 source/lib.py create mode 100644 source/main.py create mode 100644 source/packages.py create mode 100644 todo.md create mode 100755 tools/build create mode 100755 tools/install diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5c48c52 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.geany +build/ + diff --git a/hmdl.schema.json b/hmdl.schema.json new file mode 100644 index 0000000..8e6b49d --- /dev/null +++ b/hmdl.schema.json @@ -0,0 +1,299 @@ +{ + "$defs": { + "active": { + "type": "boolean" + }, + "schedule": { + "type": "object", + "additionalProperties": false, + "properties": { + "kind": { + "type": "string", + "enum": [ + "minutely", + "hourly", + "daily" + ] + } + }, + "required": [ + "kind" + ] + }, + "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" + ] + }, + { + "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" + ] + } + ] + }, + "default": [ + { + "kind": "console", + "parameters": { + } + } + ] + } + }, + "type": "object", + "additionalProperties": false, + "properties": { + "defaults": { + "description": "default values for checks", + "type": "object", + "additionalProperties": false, + "properties": { + "active": { + "$ref": "#/$defs/active" + }, + "schedule": { + "$ref": "#/$defs/schedule" + }, + "notifications": { + "$ref": "#/$defs/notifications" + } + }, + "required": [ + ] + }, + "checks": { + "type": "object", + "additionalProperties": { + "allOf": [ + { + "description": "should represent a specific check", + "type": "object", + "additionalProperties": false, + "properties": { + "title": { + "type": "string" + }, + "active": { + "$ref": "#/$defs/active" + }, + "schedule": { + "$ref": "#/$defs/schedule" + }, + "notifications": { + "$ref": "#/$defs/notifications" + } + }, + "required": [ + ] + }, + { + "anyOf": [ + { + "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" + ] + }, + { + "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" + ] + } + ] + } + ] + }, + "properties": { + }, + "required": [ + ] + } + }, + "required": [ + "defaults", + "groups" + ] +} diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..e54a70b --- /dev/null +++ b/readme.md @@ -0,0 +1,56 @@ +# Heimdall + +## Beschreibung + +- führt Prüfungen durch um den Zustand von Systemen zu überwachen und meldet Unstimmigkeiten + + +## Technologien + +- python + + +## Erstellung + +- `tools/build` + + +## Austührung + +- siehe `build/heimdall -h` +- sollte als minütlich laufender Cronjob aufgerufen werden + + +### Eigene Skripte + +Mittels den Prüfungs-Art `script`, kann man selbst definierte Prüf-Funktionen schreiben. Diese Skripte sollen durch exit-Codes das Ergebnis der Prüfung kommunizieren: + +- `0`: alles prima +- `1`: Warnung +- `2`: kritisch +- alles andere: Fehler bei Ausführung (unbekannter Status) + +Infos (z.B. was genau schief gelaufen ist) sollen zu `stdout` geschrieben werden. + + +## Testen + +TODO + + +## Ausrollen + +TODO + + +## Überwachung + +(nicht relevant) + + +## Sicherung + +(nicht relevant) + + + diff --git a/source/__pycache__/implementation_check_kind_http_request.cpython-310.pyc b/source/__pycache__/implementation_check_kind_http_request.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e358be1930aec825b6a2fdb3ba97199e50d31b50 GIT binary patch literal 2424 zcma)8L2n#26t+E{*_lbQ*(PaeTBMGUO0^YYMUMrcO3;=ADwVpTVxbzQV<$V=UC+kj zCCO$6AyOeuh$Dv_k_%G#3mkjl#@$?yIQ7601QjGa&+aChwjfsW8{6;s`<~ykpEsSG z^AY@h+V~=#n?>jsGnoES7+iyvy#j(EhP$YR;Y+%tX~v{$HPKDPEJhz7M!B_PwJ5`P zP~CnEE6}Yv*7$O4y3;=bgKN;Tmq73mvPBqfS&T620cueXz?cm^n>heh(Jv>xewX*S zP?1Vf5yovEcfw90SlCvoA4oP4D?$Z1^TW)3>3$1uc6Kgo14`l3=q5 zkifWW<(O4=EMRtTX{X0kJ7wCsdA+G;GH_s!g>lOG;~!x4jk={PV|MLIp7m3aaqUN0 zxDiQ_h}9;UFtOClD`;B(m;_(aTSfWREIGUitx0}x-l495qW1&wxyyu$2)4CfWv z!&6#k8SUf3VyK{Zk$9IPMYdkUsIV~_`ng?D&{%iT2XGh*`%_!IKKgFpji)&(z`>3< zOok^Ooa8uHa>W0^;T>}@w;;xYoMfAduG!3+!iAlkLCoJJcTf&H7$Eu8SUaneW!W$(=o4 z&9He^Gqb>4%UzJCcF7Le#~6V{g)M;X&$&BBJrAf};e-8b?k%I-2knpRkH$!gxp%B# zOS{$>=TV7t8f^an{oGzPclR+n)3N2RM_# zMKL572X)d~cowp?G(9(2p(|}3F^I{qKXz?29@DI(GU)Iv@DU_9m1NK@JveqW66|Sb zRhvKS@MzhDxG13ZS%BJ_oyKcbPnv1(e#*8&$eohdMDU6kAM-&PRFsI|1f#JIE^Tj{ zZ&6&*)O3daK(xVENJyZuUtiSTr0{3jg6wPW`ew}gWlm{7&6KWkG3c3cH_mirAUY!5 z5V|t1&f1M790Dk@+D;VjWo4GN6ZQLCFjMbMtp$Wc$kX7xriyPWO;_OQ(f<1;lh>t8 zrM9|UXp(j`T?5+U90;%PBvhw!4YonLK0G+GQMD z{wSSc8R&7p)RZsBNX#31OAf>Z=w#o5Si%%T4aZeX4_yl+u3`5xaKqd4Jc0Lsfb`B{7cUq*LO{C2!SsL}IESuR1BS^wA+G8FZ8YRBw^}>Wz`Dbc zeHD;>>K{_#TF&po%m4F*fvw%DJK7CH0Tmz&wI7DPlnuJ3KO2VYa3#l@N*J;<4ntXk zFvx}x=82sWCR)-0QGu43OYb7%52?*s<$T?4!BbxTqsZhr&_i~U`+BK-gIA&$9?+xz eALW86@RF;{hx^9J!#B;T&rHE`kqaSOvi<>}ny|Y7 literal 0 HcmV?d00001 diff --git a/source/__pycache__/implementation_check_kind_script.cpython-310.pyc b/source/__pycache__/implementation_check_kind_script.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..db41536b6961d7e734f851c02c0f69dce2fb5cc6 GIT binary patch literal 1227 zcmah|&2H2%5VoCc{<^ISEtD0PRaFi_KrV;_LZ}i#+f?S0BVI|%)>&iUnnvkp?< z1H}--IT~R2Cpp=)KFOU;^a3%5xrd0k+&ORtF2gTT+&P95Xd}i3-_5Lfei3liLF!dd zybU=Z3=bSem~)5*ZV16x2dobBAee9ZqNs9SaH&%*N=dUFo{i~PNJi^SiAqnFqww{` zkKRUMW;2~GYr`ecGmzTeTO)y{_<%qLY%7hJ$TTgu8uFMJx90h_ab?N)5mG*U|LaY% zQx-hg=2D3|sZ=@QW6hJH;#}5Qn)5_R&6P}wQVLxvA%{s`4n>w+tnB~tA5?p0NtUWe zb1~sGE9Ew|nY*^u02IOSBku3X!z)8w66Qi5IuOnh+xFor7#fZ66f@idLVIX?y?2{=O5$27Fx~-j&yYI-=7V!aD1Ed2;uSEht?gQchve+UaAddj?0r7RfB8%Qc z2lyifEBqe)K>P00nYwK5o8_XrVWKKkX~ChFwQ;Ld@0c)E!^ZMoZ^n4AkfoJw6sKjQ zt41pfw>WNs{d*~+X^AAGzGic`H{v83thR|Im>=+quc0QgnbF^-@fYFTC zwQ*DYhU9c2kH(QJfSs#D`BUN^Z`i?E@tS!Ksh_6S)D7 z@R=--2u8&2OxCU(61d?;?3sX435}-IM3fdKYjSIMDSg+Z`N@t?DJwHdRd`y;x$@dV z+Zt`tt=B%S0J$V^NYHhZ#p8~N&ibpa17w_BYz>y%2eX}KF!TSObbI8o942@&tMRGj Nx_uV`3P^yLoxin0MXLY+ literal 0 HcmV?d00001 diff --git a/source/__pycache__/implementation_notification_channel_console.cpython-310.pyc b/source/__pycache__/implementation_notification_channel_console.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2d228cda31a13a96115226f37236883a20652d26 GIT binary patch literal 900 zcmb7DPiqu06wkkz&W^39?ZIB8H(eD6JP1N51rOeOv8OF1jFap(HJhwS7U|Bemli*T z2R-_ga`og_5Ujq;YT1GazTv&Rm*oBXy^P)1C=t-}_aB-|jL_F)tP%rbAJ{(zA&|ft z4KS=klfz^aO%KsaB&c8)NH9sy>3|9R3iY#Z;DHYMIO2y*Qdfz9u@CI;f{rcSbi|Of7G@? z1uYkgV!m{O zwi)+lKIT3M*oxUZr-F<(d~nK)pj~C+t*dZcn#Ky*&m;3v4`Vs0T|JgTIv<$d>>UOb}Xx*G*06J#}VQX1dXT2{({i>b!es9;m}O52fY zs_QWSb1Y9LXEE>QcD4>l{T&cFCh+B?#5XbfK`x7*v#0+PavAObkSaJ&J#rpP&c{|v zb<)>4e><)9?~^sog>5)@n~=Ked_|;k90jok?3WLOk#*YbK>`YsaOo^ktzHlAb%+e> iM*g+w&TA!lg4v}MvDjT!w+DY2^63#4s2~O2qQ3w=U;NAf literal 0 HcmV?d00001 diff --git a/source/__pycache__/implementation_notification_channel_email.cpython-310.pyc b/source/__pycache__/implementation_notification_channel_email.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b04eb3160a7424ed55e68165efbbc2a52d195631 GIT binary patch literal 1440 zcmb7E&2Aev5GJ|YAFbusag4UQv~Ga{RZv(x=3p2>f02WY0@i7RV6k3OGTmKrLsDs@ zNWIADQ}iGm`yzb^UVG{T^kN`(hbyIUfS_o(;OuZn&hN)j+uJFE@$lzg$|n?|Km6kE z02z7=(>wyg5W^M9F#JhH>L3fSmjx9auK(c>eTNuj;T2*b4;MieF?@#l(VuV|o%XRy zPM(~6Gvq&6;PJ!Ki|-Bsi^nj{2S9jhDkBVM0V6E9LK$Tg)P=4u>Zal~m$tA%$y_Qc zMxtCV%8NouUgf+lL^a({J5S#}{VeU25$Jz25@385zJh6f2V&6@&oIM1h_i=QcplB7 zCD}-V9@=me_6SBcuyhtOG(q|!7D_w|1zL~=p2wE5h{em$Ml4~SD}OKTAsf#q+v=eO z9^?1nTVzzsP*!^V)>pkxYNFKm$v`jW9TKOzqc2;Nw z)qtIJg?v>YP5Y-VD$0@@`{Y<|P*|xHcIXs{WQsa#P!;66YB`I~jiOLLaNt z-eI?8tPejgm5`2JdK%P4<97cso^x3$#aEGPcP?6I$n)lFAQY#V z-qIlT{~JOHzK01WH}sao1XDtAgd@@=#N zAjxwdPoBFp&uhgdmFK&8{(MqYo1Hk%nJV*Ke*&)F3B^NF`xvzEa=r@~-xd-?NjGR^ z{Wyy^CDIRIE8A&%%3&>PerTbKt!ouP3EhV+bVo(W--*sv-@A+hTGxIb`?qC$BT7&g JCnUl9!QZ?SjAsA< literal 0 HcmV?d00001 diff --git a/source/__pycache__/interface_check_kind.cpython-310.pyc b/source/__pycache__/interface_check_kind.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0835afeee542800c28ed7d232e029e3141c03f00 GIT binary patch literal 652 zcmZ`%y-veG48HudgwjrQVBi7B(!2mdl@LNK5fWRMQ@C^5w8>p@(xDxp;!PO(8azf; z9)XPsI}uVr#ggsw+5YbH-PysQCrIAcuj-Bb2M7D&Q!pkihh%Pr7TVd8f_U*Jj&>hJ z=qbwl5|^SeX?act1l5U&tEqY`CZ6`RPu|xZ>h0FU%v6ABqClz{sD)f)Mk_}65nel_ z*QB3JrxMT3&WU$UeHz>9u2|+!0HNp`M2n$Y`z7RQ?Hj9MCorIvuls0b3y4xMn3d5I z?Hm>rL{kJXrAl&$8XhJptjQ{inVCkpoo1@t|HE&zT-T?@Vv*$8hKO5}NRx+KI;oOow$>bT%EOg8;`$RZo3n!qs3ZCe9M%AnrEYqK93OBzNa6^ zlGiAY$~Vdhf7EOSSlWfokfd)xN* zlbB=d`MoZ-x`CnsWn@>hM(^O!La{Lbs^hGNB8;qxMk{Hxk!n-adL#L;|EH8?yLVa8 zxKF$Q?@k&$U;J;ta$uBb)r3+%Jf&_*X`@A7`+P>}Q_t$*#GGK$AY-)nA8WjC9>KQ> hnZ;qJ%wv@Qumf&GJz{w;jHkNE^x8??G-(Mc;7bJyqCUV0RZ!FqkU+Uq;$VTcW@qATV(&V$>qKeS z7ZUh8;v4)+bLEu30ErX7H=8(43yd{y=FPr0^WN`$xL8|@7{;&j-_j~z>`!7=hYRL0 zX896CGRenm!0jpqjug^4V*^*Z(!=UWUj|rx8Oj#cK;DsU**W8b@Ea!Ax%EU zPMtv{Z^#E#)Qc8pt0Rc>6ZhSGTHxF{7JPKVW=@%M(g94ix~MP8}A`h)2fF8=$g{$4Rr z{auyotn5#9v9As))gNi4@-iKcRX>Bg&ij)h&#FRa`KUiGMp@b)XForjo|x8dHdYBe z>5;V|NW??@T&~+-QUP)`ZaCJ%>C!P#i*q=);N(){nq-%*9mkF)P2(Nw493tyIP$S* zdQT&YjXTySP2n|>waY4Wz?UQ)@rZZ0UdQ%^S#%*yi}%Oa)D6OH!Je|8IzNiJGk3{A z$!q5>tKEf|i$nhs`&a(!*AL41JQl`-d6UuvT205pRK=bNae#fL2CbnU%_e}pG`?2l zY+M=VK%E%xXgHp!#&QZtV}84=2Ig_9KX#-O~l9*}&+Dqe|m zXJPZ^lrl~1XiBo!H4b8GJYCFkeKX-Ct?kV@r+YdkCikkoZpQ>07WE zh@4D;i_Fr>L_`w+G{VU^E2}sdw9-PWggy+~lVM(=3ao5PE1*?oM{3Y+;@k*zt}rAi zeFsnIO(GWQCEWTwoY5@b0dbM8UDQ&7fS(O#J&5R4W;DUmBLBZUK0~01mKAfl|F;S9C3OR$9 ziRmQC;cPg*{1VLagSm>Q|Q&k@;x+Nb3xH`XN@q2(NKTN1If9L4>8d$YW0m9n=)=ZoHhG7hY9|E6Q2AE<<%XYJGO|A~qI1h&O|aQjVfp{6HVPnQ^DB39NXY+kF>4H7K?G8kzkKx2vmF1iYd#~G~uHr&%dP9 XE2%_7SQ0v65c**dxf|Uu=&bz%o_@mi literal 0 HcmV?d00001 diff --git a/source/heimdall.py b/source/heimdall.py new file mode 100755 index 0000000..feb7219 --- /dev/null +++ b/source/heimdall.py @@ -0,0 +1,270 @@ +#!/usr/bin/env python3 + +import sys as _sys +import os as _os +import json as _json +import argparse as _argparse + +from lib import * +from implementation_check_kind_script import * +from implementation_check_kind_http_request import * +from implementation_notification_channel_console import * +from implementation_notification_channel_email import * + + +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 conf_normalize_check(check_kind_implementations, defaults, name, node): + if ("kind" not in node): + raise ValueError("missing mandatory 'member' field 'kind'") + else: + if (node["kind"] not in check_kind_implementations): + raise ValueError("unhandled kind: %s" % node["kind"]) + else: + node_ = dict_merge( + { + "title": name, + "active": True, + "schedule": defaults["schedule"], + "notifications": defaults["notifications"], + "parameters": {}, + }, + node + ) + return { + "title": node_["title"], + "active": node_["active"], + "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": {"kind": "hourly"}, + "notifications": [], + }, + node + ) + + +def conf_normalize_root(check_kind_implementations, node): + return dict( + map( + lambda check_pair: ( + check_pair[0], + conf_normalize_check( + check_kind_implementations, + conf_normalize_defaults(node["defaults"]), + check_pair[0], + check_pair[1] + ), + ), + node["checks"].items() + ) + ) + + +def main(): + ## args + argumentparser = _argparse.ArgumentParser( + description = "monitoring processor", + formatter_class = _argparse.ArgumentDefaultsHelpFormatter + ) + argumentparser.add_argument( + "-c", + "--conf-path", + type = str, + default = "conf.json", + dest = "conf_path", + metavar = "", + help = "path to the configuration file" + ) + argumentparser.add_argument( + "-s", + "--state-path", + type = str, + default = "/tmp/monitoring-state.json", + dest = "state_path", + metavar = "", + help = "path to the state file, which contains information about the recent checks" + ) + 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( + "-x", + "--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() + + ## exec + + ### load check kind implementations + check_kind_implementations = { + "script": implementation_check_kind_script(), + "http_request": implementation_check_kind_http_request(), + } + + ### load notification channel implementations + notification_channel_implementations = { + "console": implementation_notification_channel_console(), + "email": implementation_notification_channel_email(), + } + + ### get configuration data + checks = conf_normalize_root(check_kind_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(args.state_path)): + state_data = {} + file_write(args.state_path, _json.dumps(state_data, indent = "\t")) + else: + state_data = _json.loads(file_read(args.state_path)) + + ### iterate through checks + for (check_name, check_data, ) in checks.items(): + if (not check_data["active"]): + pass + else: + ### get old state and examine whether the check shall be executed + old_item_state = ( + None + if (check_name not in state_data) else + state_decode(state_data[check_name]) + ) + timestamp = get_current_timestamp() + due = ( + (old_item_state is None) + or + ( + (old_item_state["count"] is not None) + and + ((timestamp - old_item_state["timestamp"]) >= (1 * 5)) + ) + or + ( + ( + (check_data["schedule"]["kind"] == "minutely") + and + ((timestamp - old_item_state["timestamp"]) >= (60)) + ) + or + ( + (check_data["schedule"]["kind"] == "hourly") + and + ((timestamp - old_item_state["timestamp"]) >= (60 * 60)) + ) + or + ( + (check_data["schedule"]["kind"] == "daily") + and + ((timestamp - old_item_state["timestamp"]) >= (60 * 60 * 24)) + ) + ) + ) + if (not due): + pass + else: + _sys.stderr.write( + string_coin( + "-- {{check_name}}\n", + { + "check_name": check_name, + } + ) + ) + + ### execute check and set new state + result = check_kind_implementations[check_data["kind"]].run(check_data) + 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) <= args.threshold) + ) else + None + ) + ), + } + state_data[check_name] = state_encode(new_item_state) + file_write(args.state_path, _json.dumps(state_data, indent = "\t")) + + ### send notifications + if ( + ( + (new_item_state["count"] is not None) + and + (new_item_state["count"] == args.threshold) + ) + or + ( + (new_item_state["count"] is None) + and + args.keep_notifying + ) + ): + for notification in check_data["notifications"]: + if (notification["kind"] in notification_channel_implementations): + notification_channel_implementations[notification["kind"]].notify( + notification["parameters"], + check_name, + check_data, + new_item_state, + result["output"] + ) + else: + raise ValueError("invalid notification kind: %s" % notification["kind"]) + + +main() diff --git a/source/implementation_check_kind_http_request.py b/source/implementation_check_kind_http_request.py new file mode 100644 index 0000000..b674617 --- /dev/null +++ b/source/implementation_check_kind_http_request.py @@ -0,0 +1,120 @@ +class implementation_check_kind_http_request(interface_check_kind): + + ''' + [implementation] + ''' + def normalize_conf_node(self, node): + return dict_merge( + { + "request": { + "method": "GET" + }, + "response": { + "status_code": 200 + }, + "as_warning": False, + }, + node, + True + ) + + + ''' + [implementation] + ''' + def run(self, check_data): + if (check_data["parameters"]["request"]["method"] == "GET"): + method_handled = True + try: + response = _requests.get( + check_data["parameters"]["request"]["target"] + ) + error = None + except Exception as error_: + error = error_ + response = None + elif (check_data["parameters"]["request"]["method"] == "POST"): + method_handled = True + try: + response = _requests.post( + check_data["parameters"]["request"]["target"] + ) + error = None + except Exception as error_: + error = error_ + response = None + else: + method_handled = False + response = None + if (not method_handled): + return { + "condition": enum_condition.unknown, + "output": ("invalid HTTP request method: %s" % check_data["parameters"]["request"]["method"]) + } + else: + if (response is None): + return { + "condition": ( + enum_condition.warning + if check_data["parameters"]["as_warning"] else + enum_condition.critical + ), + "output": "HTTP request failed", + } + else: + lines = [] + for (key, value, ) in check_data["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: + raise ValueError("unhandled ") + return { + "condition": ( + enum_condition.ok + if (len(lines) <= 0) else + ( + enum_condition.warning + if check_data["parameters"]["as_warning"] else + enum_condition.critical + ) + ), + "output": "\n".join(lines), + } diff --git a/source/implementation_check_kind_script.py b/source/implementation_check_kind_script.py new file mode 100644 index 0000000..5df3e2e --- /dev/null +++ b/source/implementation_check_kind_script.py @@ -0,0 +1,37 @@ +class implementation_check_kind_script(interface_check_kind): + + ''' + [implementation] + ''' + def normalize_conf_node(self, node): + return dict_merge( + { + }, + node + ) + + + ''' + [implementation] + ''' + def run(self, check_data): + result = _subprocess.run( + [check_data["parameters"]["path"]] + check_data["parameters"]["arguments"], + capture_output = True + ) + if (result.returncode == 0): + condition = enum_condition.ok + elif (result.returncode == 1): + condition = enum_condition.unknown + elif (result.returncode == 2): + condition = enum_condition.warning + elif (result.returncode == 3): + condition = enum_condition.critical + else: + raise ValueError("invalid exit code: %i" % result.returncode) + output = result.stdout.decode() + return { + "condition": condition, + "output": output, + } + diff --git a/source/implementation_notification_channel_console.py b/source/implementation_notification_channel_console.py new file mode 100644 index 0000000..06722a8 --- /dev/null +++ b/source/implementation_notification_channel_console.py @@ -0,0 +1,16 @@ +class implementation_notification_channel_console(interface_notification_channel): + + ''' + [implementation] + ''' + def notify(self, parameters, name, data, state, output): + _sys.stdout.write( + string_coin( + "[{{title}}] <{{condition}}> {{output}}\n", + { + "title": data["title"], + "condition": condition_encode(state["condition"]), + "output": ("(no infos)" if (output is None) else output), + } + ) + ) diff --git a/source/implementation_notification_channel_email.py b/source/implementation_notification_channel_email.py new file mode 100644 index 0000000..94b53bc --- /dev/null +++ b/source/implementation_notification_channel_email.py @@ -0,0 +1,44 @@ +class implementation_notification_channel_email(interface_notification_channel): + + ''' + [implementation] + ''' + def notify(self, parameters, name, data, state, output): + smtp_connection = _smtplib.SMTP( + parameters["access"]["host"] + ) + smtp_connection.login( + parameters["access"]["username"], + parameters["access"]["password"] + ) + message = MIMEText( + string_coin( + ("(no infos)" if (output is None) else output), + { + } + ) + ) + message["Subject"] = string_coin( + "{{tags}} {{title}}", + { + "tags": " ".join( + map( + lambda tag: ("[%s]" % tag.upper()), + ( + parameters["tags"] + + + [condition_encode(state["condition"])] + ) + ) + ), + "title": data["title"], + } + ) + message["From"] = parameters["sender"] + message["To"] = ",".join(parameters["receivers"]) + smtp_connection.sendmail( + parameters["sender"], + parameters["receivers"], + message.as_string() + ) + smtp_connection.quit() diff --git a/source/interface_check_kind.py b/source/interface_check_kind.py new file mode 100644 index 0000000..c7b376b --- /dev/null +++ b/source/interface_check_kind.py @@ -0,0 +1,9 @@ +class interface_check_kind(object): + + def normalize_conf_node(self, node): + raise NotImplementedError + + + def run(self, check_data): + raise NotImplementedError + diff --git a/source/interface_notification_channel.py b/source/interface_notification_channel.py new file mode 100644 index 0000000..eaaff41 --- /dev/null +++ b/source/interface_notification_channel.py @@ -0,0 +1,5 @@ +class interface_notification_channel(object): + + def notify(self, parameters, name, data, state, output): + raise NotImplementedError + diff --git a/source/lib.py b/source/lib.py new file mode 100644 index 0000000..4e687a1 --- /dev/null +++ b/source/lib.py @@ -0,0 +1,77 @@ +import enum as _enum +import time as _time + + +def file_read(path): + handle = open(path, "r") + content = handle.read() + handle.close() + return content + + +def file_write(path, content): + handle = open(path, "w") + handle.write(content) + handle.close() + + +def string_coin(template, arguments): + result = template + for (key, value, ) in arguments.items(): + result = result.replace("{{%s}}" % key, value) + return result + + +def get_current_timestamp(): + return int(round(_time.time(), 0)) + + +def dict_merge(core_dict, mantle_dict, recursive = False): + result_dict = {} + for current_dict in [core_dict, mantle_dict]: + for (key, value, ) in current_dict.items(): + if (not (key in result_dict)): + result_dict[key] = value + else: + if (recursive and (type(result_dict[key]) == dict) and (type(value) == dict)): + result_dict[key] = dict_merge(result_dict[key], value) + elif (recursive and (type(result_dict[key]) == list) and (type(value) == list)): + result_dict[key] = (result_dict[key] + value) + else: + result_dict[key] = value + return result_dict + + +class enum_condition(_enum.Enum): + unknown = 0 + ok = 1 + warning = 2 + critical = 3 + + +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) + + diff --git a/source/main.py b/source/main.py new file mode 100644 index 0000000..a328672 --- /dev/null +++ b/source/main.py @@ -0,0 +1,256 @@ +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 conf_normalize_check(check_kind_implementations, defaults, name, node): + if ("kind" not in node): + raise ValueError("missing mandatory 'member' field 'kind'") + else: + if (node["kind"] not in check_kind_implementations): + raise ValueError("unhandled kind: %s" % node["kind"]) + else: + node_ = dict_merge( + { + "title": name, + "active": True, + "schedule": defaults["schedule"], + "notifications": defaults["notifications"], + "parameters": {}, + }, + node + ) + return { + "title": node_["title"], + "active": node_["active"], + "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": {"kind": "hourly"}, + "notifications": [], + }, + node + ) + + +def conf_normalize_root(check_kind_implementations, node): + return dict( + map( + lambda check_pair: ( + check_pair[0], + conf_normalize_check( + check_kind_implementations, + conf_normalize_defaults(node["defaults"]), + check_pair[0], + check_pair[1] + ), + ), + node["checks"].items() + ) + ) + + +def main(): + ## args + argumentparser = _argparse.ArgumentParser( + description = "monitoring processor", + formatter_class = _argparse.ArgumentDefaultsHelpFormatter + ) + argumentparser.add_argument( + "-c", + "--conf-path", + type = str, + default = "conf.json", + dest = "conf_path", + metavar = "", + help = "path to the configuration file" + ) + argumentparser.add_argument( + "-s", + "--state-path", + type = str, + default = "/tmp/monitoring-state.json", + dest = "state_path", + metavar = "", + help = "path to the state file, which contains information about the recent checks" + ) + 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( + "-x", + "--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() + + ## exec + + ### load check kind implementations + check_kind_implementations = { + "script": implementation_check_kind_script(), + "http_request": implementation_check_kind_http_request(), + } + + ### load notification channel implementations + notification_channel_implementations = { + "console": implementation_notification_channel_console(), + "email": implementation_notification_channel_email(), + } + + ### get configuration data + checks = conf_normalize_root(check_kind_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(args.state_path)): + state_data = {} + file_write(args.state_path, _json.dumps(state_data, indent = "\t")) + else: + state_data = _json.loads(file_read(args.state_path)) + + ### iterate through checks + for (check_name, check_data, ) in checks.items(): + if (not check_data["active"]): + pass + else: + ### get old state and examine whether the check shall be executed + old_item_state = ( + None + if (check_name not in state_data) else + state_decode(state_data[check_name]) + ) + timestamp = get_current_timestamp() + due = ( + (old_item_state is None) + or + ( + (old_item_state["count"] is not None) + and + ((timestamp - old_item_state["timestamp"]) >= (1 * 5)) + ) + or + ( + ( + (check_data["schedule"]["kind"] == "minutely") + and + ((timestamp - old_item_state["timestamp"]) >= (60)) + ) + or + ( + (check_data["schedule"]["kind"] == "hourly") + and + ((timestamp - old_item_state["timestamp"]) >= (60 * 60)) + ) + or + ( + (check_data["schedule"]["kind"] == "daily") + and + ((timestamp - old_item_state["timestamp"]) >= (60 * 60 * 24)) + ) + ) + ) + if (not due): + pass + else: + _sys.stderr.write( + string_coin( + "-- {{check_name}}\n", + { + "check_name": check_name, + } + ) + ) + + ### execute check and set new state + result = check_kind_implementations[check_data["kind"]].run(check_data) + 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) <= args.threshold) + ) else + None + ) + ), + } + state_data[check_name] = state_encode(new_item_state) + file_write(args.state_path, _json.dumps(state_data, indent = "\t")) + + ### send notifications + if ( + ( + (new_item_state["count"] is not None) + and + (new_item_state["count"] == args.threshold) + ) + or + ( + (new_item_state["count"] is None) + and + args.keep_notifying + ) + ): + for notification in check_data["notifications"]: + if (notification["kind"] in notification_channel_implementations): + notification_channel_implementations[notification["kind"]].notify( + notification["parameters"], + check_name, + check_data, + new_item_state, + result["output"] + ) + else: + raise ValueError("invalid notification kind: %s" % notification["kind"]) + + +main() diff --git a/source/packages.py b/source/packages.py new file mode 100644 index 0000000..bb7c76e --- /dev/null +++ b/source/packages.py @@ -0,0 +1,9 @@ +import sys as _sys +import os as _os +import subprocess as _subprocess +import argparse as _argparse +import json as _json +import requests as _requests +import smtplib as _smtplib +from email.mime.text import MIMEText + diff --git a/todo.md b/todo.md new file mode 100644 index 0000000..76e0678 --- /dev/null +++ b/todo.md @@ -0,0 +1,4 @@ +- prevent parallel acces to state file? +- more resililient checks +- self check +- notification channel "Matrix" diff --git a/tools/build b/tools/build new file mode 100755 index 0000000..e9695f5 --- /dev/null +++ b/tools/build @@ -0,0 +1,16 @@ +#!/usr/bin/env sh + +mkdir -p build +echo "#!/usr/bin/env python3" > build/heimdall +cat \ + source/packages.py \ + source/lib.py \ + source/interface_check_kind.py \ + source/implementation_check_kind_script.py \ + source/implementation_check_kind_http_request.py \ + source/interface_notification_channel.py \ + source/implementation_notification_channel_console.py \ + source/implementation_notification_channel_email.py \ + source/main.py \ + >> build/heimdall +chmod +x build/heimdall diff --git a/tools/install b/tools/install new file mode 100755 index 0000000..809639b --- /dev/null +++ b/tools/install @@ -0,0 +1,3 @@ +#!/usr/bin/env sh + +cp build/heimdall /usr/local/bin/