Skip to content

Commit 9729692

Browse files
committed
initial release
0 parents  commit 9729692

File tree

11 files changed

+542
-0
lines changed

11 files changed

+542
-0
lines changed

Dockerfile

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
FROM python:3.9.2-alpine
2+
3+
RUN pip install ahab
4+
RUN pip install pyyaml
5+
RUN pip install discord-webhook
6+
RUN pip install python-telegram-bot
7+
RUN pip install slack-webhook
8+
9+
RUN mkdir /yaml
10+
RUN mkdir /deathWatch
11+
12+
ADD main.py /deathWatch/
13+
ADD configInit.py /deathWatch/
14+
ADD containerData.py /deathWatch/
15+
ADD containerEvent.py /deathWatch/
16+
ADD util.py /deathWatch/
17+
ADD discordNotify.py /deathWatch/
18+
ADD telegramNotify.py /deathWatch/
19+
ADD slackNotify.py /deathWatch/
20+
ADD logoIcons.py /deathWatch/
21+
22+
23+
WORKDIR /deathWatch/
24+
CMD [ "python", "-u", "main.py" ]

configInit.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import yaml
2+
import util
3+
from os import path
4+
from dataclasses import dataclass
5+
import sys
6+
7+
8+
@dataclass
9+
class Config:
10+
tags: list
11+
inclusions: list
12+
exclusions: list
13+
exitCodes: set
14+
restartPolicyEnabled: bool
15+
restartPolicyCountNotify: int
16+
telegramChatId: str
17+
telegramToken: str
18+
discordUrl: str
19+
slackUrl: str
20+
21+
22+
conf = Config(None, None, None, {1, 139}, False, None, None, None, None, None)
23+
24+
25+
def printSetConfig(finalConf):
26+
resultStr = "The following config params were set:\n"
27+
if finalConf.tags is not None:
28+
resultStr += f"- tags = {conf.tags}\n"
29+
if finalConf.inclusions is not None:
30+
resultStr += f"- inclusions = {conf.inclusions}\n"
31+
if finalConf.exclusions is not None:
32+
resultStr += f"- exclusions = {conf.exclusions}\n"
33+
34+
resultStr += f"- container_exit_codes = {conf.exitCodes}\n"
35+
resultStr += f"- container_restart_policy_enabled = {conf.restartPolicyEnabled}\n"
36+
37+
if finalConf.restartPolicyCountNotify is not None and finalConf.restartPolicyEnabled is True:
38+
resultStr += f"- container_restart_policy_max_count_notify = {conf.restartPolicyCountNotify}\n"
39+
if finalConf.telegramToken is not None:
40+
resultStr += f"- telegram.token = {conf.telegramToken}\n"
41+
if finalConf.telegramChatId is not None:
42+
resultStr += f"- telegram.chat_id = {conf.telegramChatId}\n"
43+
if finalConf.discordUrl is not None:
44+
resultStr += f"- discord.url = {conf.discordUrl}\n"
45+
if finalConf.slackUrl is not None:
46+
resultStr += f"- slack.url = {conf.slackUrl}\n"
47+
48+
print(resultStr)
49+
50+
51+
def initConfig():
52+
try:
53+
if path.exists('/yaml/config.yml'):
54+
with open('/yaml/config.yml') as f:
55+
docs = yaml.load_all(f, Loader=yaml.FullLoader)
56+
57+
for doc in docs:
58+
for k, v in doc.items():
59+
if k == "general_settings" and v is not None:
60+
for sk, sv in v.items():
61+
if sk == "tags":
62+
conf.tags = sv
63+
if sk == "inclusions":
64+
tmpSet = set()
65+
for item in sv:
66+
tmpSet.add(util.safeCast(item, str))
67+
conf.inclusions = set(filter(None, tmpSet))
68+
if sk == "exclusions":
69+
tmpSet = set()
70+
for item in sv:
71+
tmpSet.add(util.safeCast(item, str))
72+
conf.exclusions = set(filter(None, tmpSet))
73+
74+
# KONTEJNERI
75+
if k == "container_settings":
76+
for ck, cv in v.items():
77+
if ck == "exit_codes" and cv is not None:
78+
tmpSet = set()
79+
for item in cv:
80+
tmpSet.add(util.safeCast(item, int, None))
81+
conf.exitCodes = set(filter(None, tmpSet))
82+
if ck == "restart_policy" and cv is not None:
83+
for resKey, resVal in cv.items():
84+
if resKey == "enabled":
85+
conf.restartPolicyEnabled = util.safeCastBool(resVal, False)
86+
if resKey == "max_count_notify":
87+
conf.restartPolicyCountNotify = util.safeCast(resVal, int, None)
88+
89+
# INNTEGRACIIII
90+
if k == "integrations":
91+
for intKey, intVal in v.items():
92+
if intKey == "telegram" and intVal is not None:
93+
for telKey, telVal in intVal.items():
94+
if telKey == "token":
95+
conf.telegramToken = telVal
96+
if telKey == "chat_id":
97+
conf.telegramChatId = util.safeCast(telVal, int, None)
98+
99+
if intKey == "discord" and intVal is not None:
100+
for disKey, disVal in intVal.items():
101+
if disKey == "url":
102+
conf.discordUrl = disVal
103+
104+
if intKey == "slack" and intVal is not None:
105+
for disKey, disVal in intVal.items():
106+
if disKey == "url":
107+
conf.slackUrl = disVal
108+
109+
# default to 5 count notify if the restart policy has been enabled and notify hasn't been set
110+
if conf.restartPolicyEnabled is True and conf.restartPolicyCountNotify is None:
111+
conf.restartPolicyCountNotify = 5
112+
113+
# if the integrations are left on their default value - set them to None
114+
if conf.slackUrl == "<PASTE YOUR SLACK WEBHOOK HERE>":
115+
conf.slackUrl = None
116+
if conf.discordUrl == "<PASTE YOUR DISCORD WEBHOOK HERE>":
117+
conf.discordUrl = None
118+
if conf.telegramToken == "<PASTE YOUR TELEGRAM BOT TOKEN HERE>":
119+
conf.telegramToken = None
120+
conf.telegramChatId = None
121+
if conf.telegramChatId == "<PASTE YOUR TELEGRAM BOT CHAT_ID HERE>":
122+
conf.telegramChatId = None
123+
conf.telegramToken = None
124+
125+
if conf.telegramToken is None and conf.telegramChatId is None and conf.discordUrl is None and conf.slackUrl is None:
126+
print("ERROR: At least one integration has to be set - now exiting!")
127+
sys.exit(0)
128+
elif (conf.telegramToken is not None and conf.telegramChatId is None) or (
129+
conf.telegramToken is None and conf.telegramChatId is not None):
130+
print("ERROR: When using Telegram, both token and chat id must be set - now exiting!")
131+
sys.exit(0)
132+
133+
printSetConfig(conf)
134+
return conf
135+
136+
else:
137+
print(
138+
"ERROR: config.yml file not found (please bind the volume that contains the config.yml file) - now exiting!")
139+
sys.exit(0)
140+
141+
except Exception as e:
142+
print("ERROR: config.yml file is not a valid yml file - now exiting!", e)
143+
sys.exit(0)

containerData.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
from dataclasses import dataclass
2+
import configInit
3+
4+
5+
@dataclass
6+
class ContData:
7+
status: str
8+
exitCode: int
9+
oomKilled: str
10+
errorMsg: str
11+
restartCount: int
12+
restartPolicy: str
13+
14+
15+
def getData(rawData, conf: configInit.Config):
16+
contData = ContData(None, None, None, None, None, None)
17+
18+
try:
19+
contData.status = rawData["State"]["Status"]
20+
contData.oomKilled = rawData["State"]["OOMKilled"]
21+
contData.exitCode = int(rawData["State"]["ExitCode"])
22+
23+
contData.errorMsg = rawData["State"]["Error"]
24+
if contData.errorMsg == "":
25+
contData.errorMsg = """Please check container logs for more info.. ¯\_(ツ)_/¯"""
26+
27+
contData.restartPolicy = rawData["HostConfig"]["RestartPolicy"]["Name"]
28+
if contData.restartPolicy == "":
29+
contData.restartPolicy = None
30+
31+
contData.restartCount = rawData["RestartCount"]
32+
33+
except KeyError:
34+
pass
35+
36+
if contData.oomKilled is True or contData.status == "exited" or (
37+
conf.restartPolicyEnabled and contData.status == "restarting" and contData.restartCount <= conf.restartPolicyCountNotify):
38+
return contData
39+
40+
return None

containerEvent.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
from dataclasses import dataclass
2+
import util
3+
4+
@dataclass
5+
class ContEvent:
6+
status: str
7+
type: str
8+
action: str
9+
image: str
10+
exitCode: int
11+
name: str
12+
13+
14+
def getEvent(ev):
15+
event = ContEvent(None, None, None, None, None, None)
16+
17+
if "status" in ev:
18+
event.status = ev["status"]
19+
20+
event.type = ev["Type"]
21+
event.action = ev["Action"]
22+
23+
try:
24+
event.image = ev["Actor"]["Attributes"]["image"]
25+
event.exitCode = int(ev["Actor"]["Attributes"]["exitCode"])
26+
except KeyError:
27+
pass
28+
29+
event.name = util.safeCast(ev["Actor"]["Attributes"]["name"], str)
30+
31+
if event.type == "container":
32+
return event
33+
34+
return None
35+
36+
37+
def checkIfEventDie(event):
38+
return event.action == "die" or event.status == "die" or event.action == "oom" or event.status == "oom"
39+

discordNotify.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
from discord_webhook import DiscordWebhook, DiscordEmbed
2+
import containerEvent as eventData
3+
import containerData as dataData
4+
import configInit
5+
import sys
6+
import logoIcons
7+
8+
9+
def sendDiscordMsg(event: eventData.ContEvent, data: dataData.ContData, config: configInit.Config):
10+
webhook = DiscordWebhook(url=config.discordUrl)
11+
12+
tagsField = ""
13+
if config.tags is not None:
14+
tags = ""
15+
for t in config.tags:
16+
tags = tags + f"*{t}*; "
17+
18+
tagsField = f":flags: ** Tags **\n{tags}\n\n"
19+
20+
embedColor = 13701670
21+
authorName = "Docker Container Crashed!"
22+
authorIcon = logoIcons.getLogoUrl(event.image)
23+
24+
embedDescRestartPolicy = ""
25+
if data.restartPolicy is not None:
26+
embedDescRestartPolicy = f":repeat: **Restart Policy**: *{data.restartPolicy}*\n:repeat_one: **Max Count Restarts Notify**: *{config.restartPolicyCountNotify}*"
27+
28+
embedDesc = f"{data.errorMsg}\n\n{tagsField}{embedDescRestartPolicy}"
29+
30+
embed = DiscordEmbed(title=f":pirate_flag: {event.name.capitalize()}", description=embedDesc, color=embedColor)
31+
embed.set_author(name=authorName, icon_url=authorIcon)
32+
33+
embed.add_embed_field(name=":frame_photo: Image", value=event.image, inline=True)
34+
embed.add_embed_field(name=":anger: OOMKilled", value=str(data.oomKilled), inline=True)
35+
embed.add_embed_field(name=":small_orange_diamond: Exit Code", value=str(data.exitCode), inline=True)
36+
37+
if data.restartPolicy is not None:
38+
embed.add_embed_field(name=":arrows_counterclockwise: Current Restart Count", value=str(data.restartCount),
39+
inline=True)
40+
41+
webhook.add_embed(embed)
42+
try:
43+
webhook.execute()
44+
except Exception as e:
45+
print("ERROR: discord Url is not valid - now exiting!")
46+
sys.exit(0)

logoIcons.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
logos = {"bazarr": "https://i.imgur.com/reWArsB.png", "bitwarden": "https://i.imgur.com/7ziLQf9.png",
2+
"postgres": "https://i.imgur.com/GhkyvuT.png", "booksonic": "https://i.imgur.com/87KtEu1.png",
3+
"jackett": "https://i.imgur.com/Lz06x5b.png", "mariadb": "https://i.imgur.com/rpInfpg.png",
4+
"netdata": "https://i.imgur.com/kzLB8ou.png", "nextcloud": "https://i.imgur.com/n0sUUPo.png",
5+
"ombi": "https://i.imgur.com/dKJ3ECe.png", "organizr": "https://i.imgur.com/qJZ1kP5.png",
6+
"pihole": "https://i.imgur.com/IvLTuKF.png", "plex": "https://i.imgur.com/KtLTu8y.png",
7+
"portainer": "https://i.imgur.com/61ODRl3.png", "qbittorrent": "https://i.imgur.com/SmDt1PA.png",
8+
"radarr": "https://i.imgur.com/C4usmjf.png", "sonarr": "https://i.imgur.com/e4qj98C.png",
9+
"tautulli": "https://i.imgur.com/8XCuIQ8.png", "watchtower": "https://i.imgur.com/kiALl1f.png",
10+
"wiki": "https://i.imgur.com/2yfdYWt.png", "swag": "https://i.imgur.com/EJ1pOeM.png"}
11+
12+
13+
def getLogoUrl(name):
14+
onlyImage = name.split(":")[0].lower()
15+
for kl, vl in logos.items():
16+
if kl.find(onlyImage) != -1:
17+
return vl
18+
19+
return "https://i.imgur.com/J8k42gI.png"

main.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import configInit
2+
import containerEvent
3+
import containerData
4+
from datetime import datetime
5+
from ahab import Ahab
6+
import discordNotify
7+
import telegramNotify
8+
import slackNotify
9+
10+
11+
if __name__ == '__main__':
12+
print("STARTING SCRIPT!")
13+
14+
config = configInit.initConfig()
15+
16+
def mainProcess(event, data):
17+
# all containers will be listened for
18+
if config.inclusions is None and config.exclusions is None:
19+
manageIntegrations(event, data)
20+
# only the following containers will be excluded
21+
elif config.inclusions is None and config.exclusions is not None:
22+
if event.name not in config.exclusions:
23+
manageIntegrations(event, data)
24+
# only the following containers will be listened for
25+
elif config.inclusions is not None and config.exclusions is None:
26+
if event.name in config.inclusions:
27+
manageIntegrations(event, data)
28+
# only the following containers will be lisened for, minus all the ones in the exclusion list
29+
elif config.inclusions is not None and config.exclusions is not None:
30+
resSet = list(config.inclusions - config.exclusions)
31+
if event.name in resSet:
32+
manageIntegrations(event, data)
33+
34+
def manageIntegrations(event, data):
35+
if config.discordUrl is not None:
36+
discordNotify.sendDiscordMsg(event, data, config)
37+
if config.telegramToken is not None and config.telegramChatId is not None:
38+
telegramNotify.sendTelegramMsg(event, data, config)
39+
if config.slackUrl is not None:
40+
slackNotify.sendSlackNotify(event, data, config)
41+
42+
def processRestartPolicy(event, data):
43+
if config.restartPolicyEnabled:
44+
if event is not None and data is not None and containerEvent.checkIfEventDie(event) and \
45+
event.exitCode in config.exitCodes and data.exitCode in config.exitCodes:
46+
mainProcess(event, data)
47+
elif data is not None and data.status == "restarting":
48+
mainProcess(event, data)
49+
elif event is not None and data is not None and containerEvent.checkIfEventDie(event) and event.exitCode in \
50+
config.exitCodes and data.exitCode in config.exitCodes:
51+
mainProcess(event, data)
52+
53+
def f(rawEvent, rawData):
54+
event = containerEvent.getEvent(rawEvent)
55+
data = containerData.getData(rawData, config)
56+
57+
processRestartPolicy(event, data)
58+
59+
60+
listener = Ahab(handlers=[f])
61+
listener.listen()

0 commit comments

Comments
 (0)