Skip to content

Instantly share code, notes, and snippets.

@geberl
Last active April 30, 2019 06:54
Show Gist options
  • Save geberl/1a0e8c18ff3f63555eeff553ccb8cfbc to your computer and use it in GitHub Desktop.
Save geberl/1a0e8c18ff3f63555eeff553ccb8cfbc to your computer and use it in GitHub Desktop.
#! /usr/bin/python
# -*- coding: utf-8 -*-
import datetime
import json
import logging
import requests
from urllib import urlencode
from urllib2 import Request, urlopen
from tzlocal import get_localzone
from dateutil import parser
# Settings.
PUSH_GRACE_PERIOD = datetime.timedelta(hours=18)
LOCAL_TIMEZONE = get_localzone()
DOCKER_API_USER = 'your-username'
DOCKER_API_PASS = 'supers3cret'
MAILGUN_DOMAIN_NAME = 'your-domain.com'
MAILGUN_API_KEY = 'please-get-your-own-free-account'
# Logging config.
logger = logging.getLogger()
handler = logging.StreamHandler()
formatter = logging.Formatter('%(asctime)s %(levelname)-8s %(message)s')
handler.setFormatter(formatter)
logger.addHandler(handler)
logger.setLevel(logging.INFO)
class BaseImage(object):
def __init__(self, name, watch_tags, pulled_datetime, child_images, notify):
"""
:param name: The name of the image (can be chosen freely, not in any logic)
:param watch_tags: The tags to watch for pushes, must be named exactly as on Docker Hub, must refer to the same
data/layers underneath (size bytes checked if still identical)
:param pulled_datetime: The date/time of the pushed version that we did pull (and use for our images);
datestamps in API and Portainer/`docker image ls ...` don't match exactly, within PUSH_GRACE_PERIOD is
treated as not-newer
:param child_images: The tags of the images that use that image as their base image, and that should be rebuilt
on update of the base image; can be chosen freely, only used as text in the notification, not in any logic
:param notify: Email addresses to send notifications to if an update is available
"""
self.name = name
self.watch_tags = watch_tags
self.pulled_datetime = pulled_datetime
self.our_images = child_images
self.notify = notify
self.docker_api_token = ''
def get_token(self):
login_url = 'https://hub.docker.com/v2/users/login/'
login_post_data = {'username': DOCKER_API_USER, 'password': DOCKER_API_PASS}
request = Request(login_url, urlencode(login_post_data).encode())
data = urlopen(request).read()
data_dict = json.loads(data.decode())
self.docker_api_token = data_dict['token']
def check(self):
self.get_token()
if '/' in self.watch_tags[0]:
maintainer_name = self.watch_tags[0][:self.watch_tags[0].find('/')]
image_name = self.watch_tags[0][self.watch_tags[0].find('/')+1:self.watch_tags[0].find(':')]
else:
maintainer_name = 'library' # Docker Inc. maintained official image
image_name = self.watch_tags[0][:self.watch_tags[0].find(':')]
messages = []
first_image_bytes = None
for n, image_name_tag in enumerate(self.watch_tags):
image_tag = image_name_tag[image_name_tag.find(':')+1:]
image_tag_url = 'https://hub.docker.com/v2/repositories/%s/%s/tags/%s' % \
(maintainer_name, image_name, image_tag)
request = Request(image_tag_url)
request.add_header('Authorization', 'JWT %s' % self.docker_api_token)
data = urlopen(request).read()
data_dict = json.loads(data.decode())
if n == 0:
first_image_bytes = data_dict['full_size']
else:
this_image_bytes = data_dict['full_size']
# Size has to be identical across tags
if first_image_bytes != this_image_bytes:
messages.append('Image sizes differ! There might be a new release.')
messages.append('- %s -> %s bytes' % (self.watch_tags[0], first_image_bytes))
messages.append('- %s -> %s bytes' % (image_name_tag, this_image_bytes))
break
# Pull date is ahead upload date by about 13-15h. No idea what's going on there.
# Our pull date + PUSH_GRACE_PERIOD has to be smaller than the image upload date. Otherwise new version.
image_updated = parser.parse(data_dict['last_updated'])
pulled_datetime_with_grace = self.pulled_datetime + PUSH_GRACE_PERIOD
if pulled_datetime_with_grace < image_updated:
messages.append('New version!')
messages.append('- %s -> Ours' % self.tz_datetime_string(self.pulled_datetime))
messages.append('- %s -> Current' % self.tz_datetime_string(image_updated))
break
# Note: The different tags of same image are not uploaded at the same time, no point in checking them
if len(messages) > 0:
messages.insert(0, 'Watched tags: %s' % ', '.join(self.watch_tags))
messages.insert(1, 'Link to repo: https://hub.docker.com/r/%s/%s/' % (maintainer_name, image_name))
messages.insert(2, 'Pulled datetime: %s' % self.tz_datetime_string(self.pulled_datetime))
messages.insert(3, 'Child images: %s' % ', '.join(self.our_images))
messages.insert(4, '\n')
messages.append('\n')
messages.append('This email was sent to: %s' % ', '.join(self.notify))
self.send_email_mailgun('[email protected]',
self.notify,
'Docker Image Update Alert: "%s"' % self.name,
'\n'.join(messages))
logger.info('Update found for "%s", emails sent.' % self.name)
else:
logger.info('No update found for "%s".' % self.name)
@staticmethod
def tz_datetime_string(datetime_obj):
return datetime_obj.strftime('%Y-%m-%d %H:%M:%S %z (%Z)')
@staticmethod
def send_email_mailgun(from_addr, to_addr_list, subject, message_text):
"""
Send emails via Mailgun's API
Same code works locally in PyCharm as well as on Digitalocean or Hetzner.
Limit 10000 mails per month in free tier of service.
https://documentation.mailgun.com/api-sending.html#examples
"""
response = requests.post('https://api.mailgun.net/v3/%s/messages' % MAILGUN_DOMAIN_NAME,
auth=('api', MAILGUN_API_KEY),
data={'from': from_addr,
'to': to_addr_list,
'subject': subject,
'text': message_text})
if response.status_code != 200:
return 'Error sending mail! (code %s) (%s)' % (response.status_code, response.text)
if __name__ == '__main__':
our_base_images = []
our_base_images.append(BaseImage(name='debian:stretch',
watch_tags=['debian:stretch',
'debian:latest'],
pulled_datetime=datetime.datetime(2018, 7, 17, 2, 27, 24, tzinfo=LOCAL_TIMEZONE),
child_images=[],
notify=['[email protected]']))
our_base_images.append(BaseImage(name='debian:stretch-slim',
watch_tags=['debian:stretch-slim'],
pulled_datetime=datetime.datetime(2018, 7, 17, 2, 28, 4, tzinfo=LOCAL_TIMEZONE),
child_images=['your-namespace/your-image:latest'],
notify=['[email protected]']))
# Method does not work for the openjdk or jenkins images. Commented out for now.
# Example: API shows push date of "2018-08-03 23:53:29 +0000"
# Our image is from "2018-07-17 08:20:21 +0200"
# Manifest v1 json shows "2018-07-17 06:20:21 +0000" (in history, topmost item)
# A docker pul says "Image is up to date"
# Maybe push datetime in API is for different architecture of image? No way to get that info for now though.
for base_image in our_base_images:
base_image.check()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment