From f8230eb036d281ca4e5032399d3218ba768965a6 Mon Sep 17 00:00:00 2001 From: "Dustin C. Hatch" Date: Sat, 1 Feb 2025 12:33:03 -0600 Subject: [PATCH] callbacks: Add ntfy callback plugin This plugin sends a notification using _ntfy_ whenever a playbook fails. This will be useful especially for automated deployments when the playbook was not launched manually. --- ansible.cfg | 5 + .../__pycache__/ara_default.cpython-312.pyc | Bin 0 -> 214 bytes .../callback/__pycache__/ntfy.cpython-312.pyc | Bin 0 -> 5246 bytes plugins/callback/ntfy.py | 94 ++++++++++++++++++ 4 files changed, 99 insertions(+) create mode 100644 plugins/callback/__pycache__/ara_default.cpython-312.pyc create mode 100644 plugins/callback/__pycache__/ntfy.cpython-312.pyc create mode 100644 plugins/callback/ntfy.py diff --git a/ansible.cfg b/ansible.cfg index 81ca7dc..085bb72 100644 --- a/ansible.cfg +++ b/ansible.cfg @@ -3,6 +3,8 @@ [defaults] inventory = hosts +callback_plugins = plugins/callback + gathering = smart fact_caching = jsonfile fact_caching_connection = .fact-cache @@ -10,3 +12,6 @@ fact_caching_connection = .fact-cache force_valid_group_names = ignore remote_tmp = /var/tmp + +[callback_ntfy] +server = https://ntfy.pyrocufflink.blue diff --git a/plugins/callback/__pycache__/ara_default.cpython-312.pyc b/plugins/callback/__pycache__/ara_default.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c14c0b56c42615e1111827476d1133add54f96c8 GIT binary patch literal 214 zcmYj}F$%&k7=_>8f)t^i!L<&_1w?P)5tP`pHJBzPZHJ!3O>lJZB;KIl z-}F5m@4LtG6aX&UbHM>TefcFAVVD6pK|+$oEg*D362APHP*00ZLj7Dg!7AMr%G4|s zTJJ@AWWtG^%yQA{=8C}|K5KrkWyWP&H_C8jawm9dO|FW{YL#|;l<=Q~|4CTY_0h7l WventjMF0pu?cp5~(F4p)0DJ*=KsufP literal 0 HcmV?d00001 diff --git a/plugins/callback/__pycache__/ntfy.cpython-312.pyc b/plugins/callback/__pycache__/ntfy.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..438b5d77cd9615377cc232e47d79ddf5103ab300 GIT binary patch literal 5246 zcmbUlZERE5^<4Wsf7@}035mnUg8;!aB%~dTrO+-UX`vy8LDmir`t;`x+KB8YM)Bt038AnNN&6Xl$W2Q@9DMGy`2B@!A=NIE{HOFD@l>N=rH`E!}f z7{l(DRP~$`A3H1&EP)NcBjxmDM(Y&@azt0sLVuTVMAG9)Pg)wsy+T?a&3i~Pqv@iI zNnBI1x{^uv3a4;d7Sb7A8CBvETOxv|ID3Zmi%abvWB)uL%AXvMAH8v82m1kJUH3V~C&u4M3K=kTN#-mbB)q91c zu4hSacQ;dviON{v^o1(VJwjFM=*Tmt#Z!YPpC3GFA}DFaLLfL^wLrXPNzpVBV>0Jm?kil(TZBs} z<~s!uFAZ4EH=L1kDo#{SUV57PRyq>pm_g%cg??gI?d9LP8tL3&UTxr=@^pY=+%R6$lJSS*d9S z$mmwhtSRS;H~Yq!A3c7wZsG+yQm?gqSWD{x=Kk+0M63{$ol{0(^BZh%qPC7*1G7Ulb}ws90HC$9n+vbH zxfMcnGb-u6%WIoCTKd`CGq+t&R+_`pnQNI+vfSLg*t~zCdH)FqNdC3y+JI|BIQlNOVclC)!tOy%y5< znfh)3xD1sRp)Mxn343ks6Vz)7`X+A*3aPr7%Ro>b43t`_U=4`hK&iSDErUt1Ca;uZ z8Os7CSUJh!lww(76|Ak}qi`#5GBRu_ z<{ztog`v)z*#v{L&c6Y&{dJxN>HwVmGeE&%pcHemRgl`SY5@QYhs@oo-I9S)Ee~Qm z0@WI~`3O{N_{Jkpg};-88^06P=5J3W^S^Q$cEfQH0ABqI2MH)R4SQihYCi|Ed#$q# zr-fDU=sPWbsa@7+8B**4Tm=TCI#(?Lfb20mDVA|pf$X);`kl{u^a@_Xo9bDG$awe- z-tt@oUxO2n8E;SImsICcg=>oj~R zmReUqohG3Fz}k}68G+Q3)@&IpT=raB)_EDZSswy2ENhpCcIW|uF4_>B1_TK5aLIle zE}}?Nbi!>l3&MoYEY7hkjAk@p!XYFxL?;@H#)y;JGGo*kPfF+F*02;$O6OEe z9jv6sGIf$MC7Wdf4U>3|4U|MWqhXmM;lA>Yr3GeGv%DJu#9lQx8>l%uD%cEkgOznQ z=8C^W(+I(y$G~BtDl`GOWgy#xb?4IIXczrS=5YwGLKq>}LO1fNCrrDX-{woKP;6QPR{QWwSUf zQ%6oyRplJJ+4DI}bm}}=9cV0nQU^}QGcu+gNtVSVmSn6^XHHWyS)8WM1lG-6PQ#L{ zF^f@$jM-A8p<1qNx9T3#b#kgsC~rC+<#p3y9Gg*IRnnMHhlUvrvmpBw^-DT+j$=Zk z1g3TkpQkPjlWZnUF!i!3B}YV40Kc^?vfCi+ajsT^b@4^dGUrWGg~`HVL&wJr9rLb^`G$@=O&eyooBr$m#g@Gb zEqlvNJw;b#U8v~16KuLVI32wfT?}4_!aB*w(qw);T*e*HdoWSJ|@t=EU`hn=f5|X;z-=D{t9f32(XCb-imb z+_@00VukmuNx2rcHP-&q;Mx=@K6`cGmEk2jtPd}Rps}gQS2ngY>EAd$d8{~ab!Vk@ z`B%j>okop%DEsq|!eF|d6hu)X37P7O~ESDIQYjV;rguWc?l%Z?F;_)QvYrL zuGu4%@V3S9?uGE~a=5cNRA~s$ocg#yC=Ja$@$vTk6=By~{$Kll+i=x?r+M4_j>8`w zTI_#eq5p+)|LOU|vHA9&mYdJa2hS|=u;cq*@DPYB`gbk(cYQ@zdHJ_}ea<0={UZlI z)ZmQRqtR%@&pKKb%c4jaX?5ajdGM7(vxWiZzx$thP|B*f#&M z)I!*^_T?3*YzWVDp-O1m<>5*we0lgY&c<*0+~wf+F9i|5b@nIs0P$Ow9NYP`XtwJM zV87K4h_Q*!`#(Ey0G&lw&wLK-*W~tp D9iQ2) literal 0 HcmV?d00001 diff --git a/plugins/callback/ntfy.py b/plugins/callback/ntfy.py new file mode 100644 index 0000000..dd95c0e --- /dev/null +++ b/plugins/callback/ntfy.py @@ -0,0 +1,94 @@ +from pathlib import Path +import urllib.request + +from ansible.errors import AnsibleError +from ansible.executor.stats import AggregateStats +from ansible.playbook import Playbook +from ansible.plugins.callback import CallbackBase + + +DOCUMENTATION = r''' +author: Dustin C. Hatch +name: ntfy +short_description: Send notifications to ntfy.sh +description: +- This plugin sends playbook failure notifications via ntfy.sh. +options: + server: + description: ntfy.sh server + type: str + default: https://ntfy.sh + env: + - name: NTFY_SERVER + ini: + - section: callback_ntfy + key: server + topic: + description: ntfy.sh topic name + type: str + default: ansible + env: + - name: NTFY_TOPIC + ini: + - section: callback_ntfy + key: topic +''' + + +class CallbackModule(CallbackBase): + CALLBACK_VERSION = 1.0 + CALLBACK_TYPE = 'notification' + CALLBACK_NAME = 'ntfy' + + def __init__(self): + super().__init__() + self.playbook = None + + def set_options(self, task_keys=None, var_options=None, direct=None): + super().set_options(task_keys, var_options, direct) + ntfy_server = self.get_option('server').rstrip('/') + if '://' not in ntfy_server: + ntfy_server = f'http://{ntfy_server}' + ntfy_topic = self.get_option('topic') + self.ntfy_url = f'{ntfy_server}/{ntfy_topic}' + + def v2_playbook_on_start(self, playbook: Playbook): + self.playbook = playbook + + def v2_playbook_on_stats(self, stats: AggregateStats): + if not self.playbook: + return + if not stats.failures and not stats.dark: + return + assert self.playbook._file_name + playbook = Path(self.playbook._file_name) + results = {} + hosts = set(stats.failures.keys()).union(stats.dark.keys()) + title = f'Playbook {playbook.name} failed for {len(hosts)} hosts' + for host in hosts: + results[host] = { + 'ok': stats.ok.get(host, 0), + 'changed': stats.changed.get(host, 0), + 'unreachable': stats.dark.get(host, 0), + 'failed': stats.failures.get(host, 0), + 'skipped': stats.skipped.get(host, 0), + 'rescued': stats.rescued.get(host, 0), + 'ignored': stats.ignored.get(host, 0), + } + lines = [] + for host, result in results.items(): + result_txt = ' '.join(f'{k}={v}' for k, v in result.items()) + lines.append(f'{host} : {result_txt}') + message = '\n'.join(lines) + req = urllib.request.Request( + self.ntfy_url, + method='POST', + data=message.encode('utf-8'), + ) + req.add_header('Title', title) + req.add_header('Tag', 'red_circle') + with urllib.request.urlopen(req) as response: + status_code = response.getcode() + if status_code < 200 or status_code >= 300: + response_data = response.read() + raise AnsibleError(f'Failed to send notification: {response_data.decode()}')