Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor/notifications with apprise #151

Merged
merged 9 commits into from
Jan 26, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ A python-based successor to [watchtower](https://github.com/v2tec/watchtower)
Ouroboros will monitor (all or specified) running docker containers and update them to the (latest or tagged) available image in the remote registry. The updated container uses the same tag and parameters that were used when the container was first created such as volume/bind mounts, docker network connections, environment variables, restart policies, entrypoints, commands, etc.

- Push your image to your registry and simply wait your defined interval for ouroboros to find the new image and redeploy your container autonomously.
- Notify you via email or platform customized webhooks. (Currently: Discord/Slack/Pushover/HealthChecks/Generic)
- Notify you via many platforms courtesy of [Apprise](https://github.com/caronc/apprise)
- Serve metrics for trend monitoring (Currently: Prometheus/Influxdb)
- Limit your server ssh access
- `ssh -i key server.domainname "docker pull ... && docker run ..."` is for scrubs
Expand Down Expand Up @@ -55,7 +55,7 @@ pip install ouroboros-cli
And can then be invoked using the `ouroboros` command:

```bash
$ ouroboros --interval 300 --loglevel debug
$ ouroboros --interval 300 --log-level debug
```

> This can be useful if you would like to create a `systemd` service or similar daemon that doesn't run in a container
Expand Down
2 changes: 1 addition & 1 deletion pyouroboros/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
VERSION = "1.0.1"
VERSION = "1.1.0"
BRANCH = "develop"
41 changes: 7 additions & 34 deletions pyouroboros/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,10 @@

class Config(object):
options = ['INTERVAL', 'PROMETHEUS', 'DOCKER_SOCKETS', 'MONITOR', 'IGNORE', 'LOG_LEVEL', 'PROMETHEUS_ADDR',
'PROMETHEUS_PORT', 'WEBHOOK_URLS', 'REPO_USER', 'REPO_PASS', 'CLEANUP', 'RUN_ONCE', 'LATEST',
'PROMETHEUS_PORT', 'NOTIFIERS', 'REPO_USER', 'REPO_PASS', 'CLEANUP', 'RUN_ONCE', 'LATEST',
'INFLUX_URL', 'INFLUX_PORT', 'INFLUX_USERNAME', 'INFLUX_PASSWORD', 'INFLUX_DATABASE', 'INFLUX_SSL',
'INFLUX_VERIFY_SSL', 'DATA_EXPORT', 'PUSHOVER_TOKEN', 'PUSHOVER_USER', 'PUSHOVER_DEVICE', 'SMTP_HOST',
'SMTP_PORT', 'SMTP_STARTTLS', 'SMTP_USERNAME', 'SMTP_PASSWORD', 'SMTP_RECIPIENTS', 'SMTP_FROM_EMAIL',
'SMTP_FROM_NAME', 'SELF_UPDATE', 'LABEL_ENABLE', 'DOCKER_TLS_VERIFY', 'LABELS_ONLY', 'DRY_RUN']
'INFLUX_VERIFY_SSL', 'DATA_EXPORT', 'SELF_UPDATE', 'LABEL_ENABLE', 'DOCKER_TLS_VERIFY', 'LABELS_ONLY',
'DRY_RUN']

interval = 300
docker_sockets = 'unix://var/run/docker.sock'
Expand Down Expand Up @@ -41,20 +40,7 @@ class Config(object):
influx_password = 'root'
influx_database = None

webhook_urls = []

pushover_token = None
pushover_user = None
pushover_device = None

smtp_host = None
smtp_port = 587
smtp_starttls = False
smtp_username = None
smtp_password = None
smtp_recipients = None
smtp_from_email = None
smtp_from_name = 'Ouroboros'
notifiers = []

def __init__(self, environment_vars, cli_args):
self.cli_args = cli_args
Expand Down Expand Up @@ -90,14 +76,14 @@ def config_blacklist(self):
def parse(self):
for option in Config.options:
if self.environment_vars.get(option):
if option in ['INTERVAL', 'PROMETHEUS_PORT', 'INFLUX_PORT', 'SMTP_PORT']:
if option in ['INTERVAL', 'PROMETHEUS_PORT', 'INFLUX_PORT']:
try:
opt = int(self.environment_vars[option])
setattr(self, option.lower(), opt)
except ValueError as e:
print(e)
elif option in ['LATEST', 'CLEANUP', 'RUN_ONCE', 'INFLUX_SSL', 'INFLUX_VERIFY_SSL', 'DRY_RUN',
'SMTP_STARTTLS', 'SELF_UPDATE', 'LABEL_ENABLE', 'DOCKER_TLS_VERIFY', 'LABELS_ONLY']:
'SELF_UPDATE', 'LABEL_ENABLE', 'DOCKER_TLS_VERIFY', 'LABELS_ONLY']:
if self.environment_vars[option].lower() in ['true', 'yes']:
setattr(self, option.lower(), True)
elif self.environment_vars[option].lower() in ['false', 'no']:
Expand All @@ -117,7 +103,7 @@ def parse(self):
if self.interval < 30:
self.interval = 30

for option in ['docker_sockets', 'webhook_urls', 'smtp_recipients', 'monitor', 'ignore']:
for option in ['docker_sockets', 'notifiers', 'monitor', 'ignore']:
if isinstance(getattr(self, option), str):
string_list = getattr(self, option)
setattr(self, option, [string.strip(' ').strip('"') for string in string_list.split(' ')])
Expand All @@ -130,19 +116,6 @@ def parse(self):
if self.data_export == 'prometheus' and self.self_update:
self.logger.warning("If you bind a port to ouroboros, it will be lost when it updates itself.")

pushover_config = [self.pushover_token, self.pushover_device, self.pushover_user]
if any(pushover_config) and not all(pushover_config):
self.logger.error('You must specify a pushover user, token, and device to use pushover. Disabling '
'pushover notifications')
elif all(pushover_config):
self.webhook_urls.append('https://api.pushover.net/1/messages.json')

email_config = [self.smtp_host, self.smtp_recipients, self.smtp_from_email]
if any(email_config) and not all(email_config):
self.logger.error('To use email notifications, you need to specify at least smtp host/recipients/from '
'email. Disabling email notifications')
self.smtp_host = None

if self.dry_run and not self.run_once:
self.logger.warning("Dry run is designed to be ran with run once. Setting for you.")
self.run_once = True
Expand Down
5 changes: 1 addition & 4 deletions pyouroboros/dockerclient.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,10 +208,7 @@ def update_containers(self):
container.restart()

if updated_count > 0:
self.notification_manager.send(container_tuples=updated_container_tuples, socket=self.socket,
notification_type='data')

self.notification_manager.send(notification_type='keep_alive')
self.notification_manager.send(container_tuples=updated_container_tuples, socket=self.socket, kind='update')

def update_self(self, count=None, old_container=None, me_list=None, new_image=None):
if count == 2:
Expand Down
3 changes: 2 additions & 1 deletion pyouroboros/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@ def set_properties(old, new, self_name=None):
return properties


EMAIL_TEMPLATE = Template(
NotificationTemplate = Template(
'Host Socket: ${HOST_SOCKET}\n'
'Containers Monitored: ${CONTAINERS_MONITORED}\n'
'Containers Updated: ${CONTAINERS_UPDATED}\n'
'Containers Updated This Pass: {CONTAINERS_THIS_PASS}'
'${CONTAINER_UPDATES}'
)
235 changes: 50 additions & 185 deletions pyouroboros/notifiers.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,7 @@
import requests
import apprise

from email.message import EmailMessage
from smtplib import SMTP, SMTPConnectError, SMTPAuthenticationError, SMTPServerDisconnected, SMTPException
from logging import getLogger
from datetime import datetime, timezone
from requests.exceptions import RequestException

from pyouroboros.helpers import EMAIL_TEMPLATE
from datetime import datetime, timezone, timedelta


class NotificationManager(object):
Expand All @@ -15,184 +10,54 @@ def __init__(self, config, data_manager):
self.data_manager = data_manager
self.logger = getLogger()

self.email = Email(self.config, self.data_manager)
self.webhooks = Webhooks(self.config, self.data_manager)

def send(self, container_tuples=None, socket=None, notification_type='data'):
if self.email.server and notification_type == 'data':
self.email.send(container_tuples, socket)

if self.config.webhook_urls:
self.webhooks.send(container_tuples, socket, notification_type)


class Email(object):
def __init__(self, config, data_manager):
self.config = config
self.data_manager = data_manager

self.logger = getLogger()
if self.config.smtp_host:
self.server = True
self.apprise = self.build_apprise()

def build_apprise(self):
asset = apprise.AppriseAsset(
image_url_mask='https://bin.cajun.pro/images/ouroboros/notifications/ouroboros-logo-{XY}{EXTENSION}',
default_extension='.png'
)
asset.app_id = "Ouroboros"
asset.app_desc = "Ouroboros"
asset.app_url = "https://github.com/pyouroboros/ouroboros"
asset.html_notify_map['info'] = '#5F87C6'
asset.image_url_logo = 'https://bin.cajun.pro/images/ouroboros/notifications/ouroboros-logo-256x256.png'

apprise_obj = apprise.Apprise(asset=asset)

for notifier in self.config.notifiers:
add = apprise_obj.add(notifier)
if not add:
self.logger.error('Could not add notifier %s', notifier)

return apprise_obj

def send(self, container_tuples=None, socket=None, kind='update'):
if kind == 'startup':
now = datetime.now(timezone.utc).astimezone()
title = f'Ouroboros has started'
body_fields = [
f'Time: {now.strftime("%Y-%m-%d %H:%M:%S")}',
f'Next Run: {(now + timedelta(0, self.config.interval)).strftime("%Y-%m-%d %H:%M:%S")}'
]
else:
self.server = False

def get_server(self):
try:
server = SMTP(
host=self.config.smtp_host,
port=self.config.smtp_port
)
if self.config.smtp_starttls:
server.starttls()
if self.config.smtp_username and self.config.smtp_password:
server.login(self.config.smtp_username, self.config.smtp_password)
return server
except SMTPConnectError as e:
self.logger.error('Could not connect to SMTP host %s on port %s. Disabling SMTP. Error: %s',
self.config.smtp_host, self.config.smtp_port, e)
return
except SMTPAuthenticationError as e:
self.logger.error('SMTP host did not accept credentials. Disabling SMTP. Error %s', e)
return

def send(self, container_tuples, socket):
for address in self.config.smtp_recipients:
msg = EmailMessage()
msg['Subject'] = 'Ouroboros has updated containers!'
msg['From'] = f"{self.config.smtp_from_name} <{self.config.smtp_from_email}>"
msg['To'] = address

container_updates = ''
for container, old_image, new_image in container_tuples:
container_updates += "{} updated from {} to {}\n".format(
container.name,
old_image.short_id.split(":")[1],
new_image.short_id.split(":")[1]
)

template = EMAIL_TEMPLATE.substitute(
CONTAINERS_MONITORED=self.data_manager.monitored_containers[socket],
CONTAINERS_UPDATED=self.data_manager.total_updated[socket],
HOST_SOCKET=socket.split("//")[1],
CONTAINER_UPDATES=container_updates)

msg.set_content(template)
try:
server = self.get_server()
server.send_message(msg)
except SMTPServerDisconnected as e:
self.server = False
self.logger.error('Could not properly talk to SMTP server. Disabling SMTP. Error: %s', e)
except SMTPException as e:
self.server = False
self.logger.error('SMTP Error: %s', e)


class Webhooks(object):
def __init__(self, config, data_manager):
self.config = config
self.data_manager = data_manager

self.logger = getLogger()

def send(self, container_tuples, socket, notification_type):
formatted_webhooks = []
for webhook_url in self.config.webhook_urls:
if notification_type == "keep_alive":
if "hc-ping" in webhook_url:
formatted_webhooks.append((webhook_url, {}))
else:
if 'discord' in webhook_url:
format_type = 'discord'
elif 'slack' in webhook_url:
format_type = 'slack'
elif 'pushover' in webhook_url:
format_type = 'pushover'
elif 'hc-ping' in webhook_url:
continue
else:
format_type = 'default'

formatted_webhooks.append((webhook_url, self.format(container_tuples, socket, format_type)))

self.post(formatted_webhooks)

def format(self, container_tuples, socket, format_type):
clean_socket = socket.split("//")[1]
now = str(datetime.now(timezone.utc)).replace(" ", "T")
if format_type in ['slack', 'default', 'pushover']:
text = "Host Socket: {}\n".format(clean_socket)
text += "Containers Monitored: {}\n".format(self.data_manager.monitored_containers[socket])
text += "Containers Updated: {}\n".format(self.data_manager.total_updated[socket])
for container, old_image, new_image in container_tuples:
text += "{} updated from {} to {}\n".format(
container.name,
old_image.short_id.split(":")[1],
new_image.short_id.split(":")[1]
)
text += now
if format_type == 'pushover':
json = {
"html": 1,
"token": self.config.pushover_token,
"user": self.config.pushover_user,
"device": self.config.pushover_device,
"title": "Ouroboros has updated containers!",
"message": text
}
else:
json = {"text": text}
return json

elif format_type == 'discord':
json = {
"embeds": [
{
"title": "Ouroboros has updated containers!",
"description": "Breakdown:",
"color": 316712,
"timestamp": now,
"thumbnail": {
"url": "https://bin.cajun.pro/images/ouroboros/ouroboros_logo_primary_cropped.png"
},
"fields": [
{
"name": "Socket:",
"value": f"{clean_socket}"
},
{
"name": "Containers Monitored",
"value": f"{self.data_manager.monitored_containers[socket]}",
"inline": True
},
{
"name": "Containers Updated",
"value": f"{self.data_manager.total_updated[socket]}",
"inline": True
}
]
}
title = 'Ouroboros has updated containers!'
body_fields = [
f"Host Socket: {socket.split('//')[1]}",
f"Containers Monitored: {self.data_manager.monitored_containers[socket]}",
f"Total Containers Updated: {self.data_manager.total_updated[socket]}",
f"Containers updated this pass: {len(container_tuples)}"
]
body_fields.extend(
[
"{} updated from {} to {}".format(
container.name,
old_image.short_id.split(':')[1],
new_image.short_id.split(':')[1]
) for container, old_image, new_image in container_tuples
]
}
for container, old_image, new_image in container_tuples:
json['embeds'][0]['fields'].append(
{
"name": container.name,
"value": 'Old SHA: {} | New SHA: {}'.format(
old_image.short_id.split(":")[1],
new_image.short_id.split(":")[1]
)
}
)
return json
)
body = '\n'.join(body_fields)

def post(self, webhook_tuples):
"""POST webhook for notifications"""
for url, json in webhook_tuples:
try:
headers = {'Content-Type': 'application/json', 'user-agent': 'ouroboros'}
p = requests.post(url, json=json, headers=headers)
self.logger.debug("Sent webhook successfully to %s | status code %s", url, p)
except RequestException as e:
self.logger.error("Error Posting to Webhook url %s | error %s", url, e)
if self.apprise.servers:
self.apprise.notify(title=title, body=body)
Loading