diff --git a/packages/addons/service/docker/changelog.txt b/packages/addons/service/docker/changelog.txt index 11fe65a9eb..a86e44f509 100644 --- a/packages/addons/service/docker/changelog.txt +++ b/packages/addons/service/docker/changelog.txt @@ -1,3 +1,6 @@ +7.0.103 +- Allow using kodi notifications based on Docker events API + 7.0.102 - Update to docker 1.11.1 diff --git a/packages/addons/service/docker/package.mk b/packages/addons/service/docker/package.mk index 56d37d4361..5ff2c7487a 100644 --- a/packages/addons/service/docker/package.mk +++ b/packages/addons/service/docker/package.mk @@ -18,7 +18,7 @@ PKG_NAME="docker" PKG_VERSION="1.11.1" -PKG_REV="102" +PKG_REV="103" PKG_ARCH="any" PKG_ADDON_PROJECTS="Generic RPi RPi2" PKG_LICENSE="ASL" diff --git a/packages/addons/service/docker/source/default.py b/packages/addons/service/docker/source/default.py index 807ce9432b..cc5fa4ff28 100644 --- a/packages/addons/service/docker/source/default.py +++ b/packages/addons/service/docker/source/default.py @@ -19,13 +19,13 @@ import os import subprocess import sys +import threading import time import xbmc import xbmcaddon import xbmcgui sys.path.append('/usr/share/kodi/addons/service.libreelec.settings') - import oe __author__ = 'lrusak' @@ -36,20 +36,257 @@ __servicename__ = __addon__.getAddonInfo('id') + '.service' __socket__ = __path__ + '/systemd/' + __addon__.getAddonInfo('id') + '.socket' __socketname__ = __addon__.getAddonInfo('id') + '.socket' +sys.path.append(__path__ + '/lib') +import dockermon + +# docker events for api 1.23 (docker version 1.11.x) +# https://docs.docker.com/engine/reference/api/docker_remote_api_v1.23/#monitor-docker-s-events + +docker_events = { + 'container': { + 'string': 30030, + 'event': { + 'attach': { + 'string': 30031, + 'enabled': '', + }, + 'commit': { + 'string': 30032, + 'enabled': '', + }, + 'copy': { + 'string': 30033, + 'enabled': '', + }, + 'create': { + 'string': 30034, + 'enabled': '', + }, + 'destroy': { + 'string': 30035, + 'enabled': '', + }, + 'die': { + 'string': 30036, + 'enabled': '', + }, + 'exec_create': { + 'string': 30037, + 'enabled': '', + }, + 'exec_start': { + 'string': 30038, + 'enabled': '', + }, + 'export': { + 'string': 30039, + 'enabled': '', + }, + 'kill': { + 'string': 30040, + 'enabled': True, + }, + 'oom': { + 'string': 30041, + 'enabled': True, + }, + 'pause': { + 'string': 30042, + 'enabled': '', + }, + 'rename': { + 'string': 30043, + 'enabled': '', + }, + 'resize': { + 'string': 30044, + 'enabled': '', + }, + 'restart': { + 'string': 30045, + 'enabled': '', + }, + 'start': { + 'string': 30046, + 'enabled': True, + }, + 'stop': { + 'string': 30047, + 'enabled': True, + }, + 'top': { + 'string': 30048, + 'enabled': '', + }, + 'unpause': { + 'string': 30049, + 'enabled': '', + }, + 'update': { + 'string': 30050, + 'enabled': '', + }, + }, + }, + 'image': { + 'string': 30060, + 'event': { + 'delete': { + 'string': 30061, + 'enabled': '', + }, + 'import': { + 'string': 30062, + 'enabled': '', + }, + 'pull': { + 'string': 30063, + 'enabled': True, + }, + 'push': { + 'string': 30064, + 'enabled': '', + }, + 'tag': { + 'string': 30065, + 'enabled': '', + }, + 'untag': { + 'string': 30066, + 'enabled': '', + }, + }, + }, + 'volume': { + 'string': 30070, + 'event': { + 'create': { + 'string': 30071, + 'enabled': '', + }, + 'mount': { + 'string': 30072, + 'enabled': '', + }, + 'unmount': { + 'string': 30073, + 'enabled': '', + }, + 'destroy': { + 'string': 30074, + 'enabled': '', + }, + }, + }, + 'network': { + 'string': 30080, + 'event': { + 'create': { + 'string': 30081, + 'enabled': '', + }, + 'connect': { + 'string': 30082, + 'enabled': '', + }, + 'disconnect': { + 'string': 30083, + 'enabled': '', + }, + 'destroy': { + 'string': 30084, + 'enabled': '', + }, + }, + }, + } + +def print_notification(json_data): + event_string = docker_events[json_data['Type']]['event'][json_data['Action']]['string'] + if __addon__.getSetting('notifications') is '0': # default + if docker_events[json_data['Type']]['event'][json_data['Action']]['enabled']: + try: + message = unicode(' '.join([__addon__.getLocalizedString(30010), + json_data['Actor']['Attributes']['name'], + '|', + __addon__.getLocalizedString(30012), + __addon__.getLocalizedString(event_string)])) + except KeyError as e: + message = unicode(' '.join([__addon__.getLocalizedString(30011), + json_data['Type'], + '|', + __addon__.getLocalizedString(30012), + __addon__.getLocalizedString(event_string)])) + + elif __addon__.getSetting('notifications') is '1': # all + try: + message = unicode(' '.join([__addon__.getLocalizedString(30010), + json_data['Actor']['Attributes']['name'], + '|', + __addon__.getLocalizedString(30012), + __addon__.getLocalizedString(event_string)])) + except KeyError as e: + message = unicode(' '.join([__addon__.getLocalizedString(30011), + json_data['Type'], + '|', + __addon__.getLocalizedString(30012), + __addon__.getLocalizedString(event_string)])) + + elif __addon__.getSetting('notifications') is '2': # none + pass + + elif __addon__.getSetting('notifications') is '3': # custom + if __addon__.getSetting(json_data['Action']) == 'true': + try: + message = unicode(' '.join([__addon__.getLocalizedString(30010), + json_data['Actor']['Attributes']['name'], + '|', + __addon__.getLocalizedString(30012), + __addon__.getLocalizedString(event_string)])) + except KeyError as e: + message = unicode(' '.join([__addon__.getLocalizedString(30011), + json_data['Type'], + '|', + __addon__.getLocalizedString(30012), + __addon__.getLocalizedString(event_string)])) + + dialog = xbmcgui.Dialog() + try: + if message is not '': + length = int(__addon__.getSetting('notification_length')) * 1000 + dialog.notification('Docker', message, '/storage/.kodi/addons/service.system.docker/icon.png', length) + xbmc.log('## service.system.docker ## ' + unicode(message)) + except NameError as e: + pass + +class dockermonThread(threading.Thread): + + def __init__(self): + threading.Thread.__init__(self) + self._is_running = True + + def run(self): + while self._is_running: + dockermon.watch(print_notification) + + def stop(self): + self._is_running = False + class Main(object): def __init__(self, *args, **kwargs): monitor = DockerMonitor(self) - if not Docker().is_enabled(): - Docker().enable() + if not Docker().is_active(): + if not Docker().is_enabled(): + Docker().enable() Docker().start() while not monitor.abortRequested(): if monitor.waitForAbort(): - Docker().stop() - Docker().disable() + # we don't want to stop or disable docker while it's installed + pass class Docker(object): @@ -95,9 +332,11 @@ class DockerMonitor(xbmc.Monitor): xbmc.Monitor.__init__(self) def onSettingsChanged(self): - Docker().restart() + pass if ( __name__ == "__main__" ): + dockermonThread().start() Main() del DockerMonitor + dockermonThread().stop() diff --git a/packages/addons/service/docker/source/lib/dockermon.py b/packages/addons/service/docker/source/lib/dockermon.py new file mode 100755 index 0000000000..e66e1025de --- /dev/null +++ b/packages/addons/service/docker/source/lib/dockermon.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python +"""docker monitor using docker /events HTTP streaming API""" +"""https://github.com/CyberInt/dockermon""" + +from contextlib import closing +from functools import partial +from socket import socket, AF_UNIX +from subprocess import Popen, PIPE +from sys import stdout, version_info +import json +import shlex + +if version_info[:2] < (3, 0): + from httplib import OK as HTTP_OK + from urlparse import urlparse +else: + from http.client import OK as HTTP_OK + from urllib.parse import urlparse + +__version__ = '0.2.2' +# buffer size must be 256 or lower otherwise events won't show in realtime +bufsize = 256 +default_sock_url = 'ipc:///var/run/docker.sock' + + +class DockermonError(Exception): + pass + + +def read_http_header(sock): + """Read HTTP header from socket, return header and rest of data.""" + buf = [] + hdr_end = '\r\n\r\n' + + while True: + buf.append(sock.recv(bufsize).decode('utf-8')) + data = ''.join(buf) + i = data.find(hdr_end) + if i == -1: + continue + return data[:i], data[i + len(hdr_end):] + + +def header_status(header): + """Parse HTTP status line, return status (int) and reason.""" + status_line = header[:header.find('\r')] + # 'HTTP/1.1 200 OK' -> (200, 'OK') + fields = status_line.split(None, 2) + return int(fields[1]), fields[2] + + +def connect(url): + """Connect to UNIX or TCP socket. + + url can be either tcp://:port or ipc:// + """ + url = urlparse(url) + if url.scheme == 'tcp': + sock = socket() + netloc = tuple(url.netloc.rsplit(':', 1)) + hostname = socket.gethostname() + elif url.scheme == 'ipc': + sock = socket(AF_UNIX) + netloc = url.path + hostname = 'localhost' + else: + raise ValueError('unknown socket type: %s' % url.scheme) + + sock.connect(netloc) + return sock, hostname + + +def watch(callback, url=default_sock_url): + """Watch docker events. Will call callback with each new event (dict). + + url can be either tcp://:port or ipc:// + """ + sock, hostname = connect(url) + request = 'GET /events HTTP/1.1\nHost: %s\n\n' % hostname + request = request.encode('utf-8') + + with closing(sock): + sock.sendall(request) + header, payload = read_http_header(sock) + status, reason = header_status(header) + if status != HTTP_OK: + raise DockermonError('bad HTTP status: %s %s' % (status, reason)) + + # Messages are \r\n\r\n + buf = [payload] + while True: + chunk = sock.recv(bufsize) + if not chunk: + raise EOFError('socket closed') + buf.append(chunk.decode('utf-8')) + data = ''.join(buf) + i = data.find('\r\n') + if i == -1: + continue + + size = int(data[:i], 16) + start = i + 2 # Skip initial \r\n + + if len(data) < start + size + 2: + continue + payload = data[start:start+size] + callback(json.loads(payload)) + buf = [data[start+size+2:]] # Skip \r\n suffix + + +def print_callback(msg): + """Print callback, prints message to stdout as JSON in one line.""" + json.dump(msg, stdout) + stdout.write('\n') + stdout.flush() + + +def prog_callback(prog, msg): + """Program callback, calls prog with message in stdin""" + pipe = Popen(prog, stdin=PIPE) + data = json.dumps(msg) + pipe.stdin.write(data.encode('utf-8')) + pipe.stdin.close() + + +if __name__ == '__main__': + from argparse import ArgumentParser + + parser = ArgumentParser(description=__doc__) + parser.add_argument('--prog', default=None, + help='program to call (e.g. "jq --unbuffered .")') + parser.add_argument( + '--socket-url', default=default_sock_url, + help='socket url (ipc:///path/to/sock or tcp:///host:port)') + parser.add_argument( + '--version', help='print version and exit', + action='store_true', default=False) + args = parser.parse_args() + + if args.version: + print('dockermon %s' % __version__) + raise SystemExit + + if args.prog: + prog = shlex.split(args.prog) + callback = partial(prog_callback, prog) + else: + callback = print_callback + + try: + watch(callback, args.socket_url) + except (KeyboardInterrupt, EOFError): + pass diff --git a/packages/addons/service/docker/source/resources/language/English/strings.po b/packages/addons/service/docker/source/resources/language/English/strings.po new file mode 100644 index 0000000000..b5a0a465e9 --- /dev/null +++ b/packages/addons/service/docker/source/resources/language/English/strings.po @@ -0,0 +1,197 @@ +# Kodi Media Center language file +# Addon Name: docker +# Addon id: service.system.docker +msgid "" +msgstr "" + +msgctxt "#30000" +msgid "Settings" +msgstr "" + +msgctxt "#30001" +msgid "Notifications" +msgstr "" + +msgctxt "#30002" +msgid "Default" +msgstr "" + +msgctxt "#30003" +msgid "All" +msgstr "" + +msgctxt "#30004" +msgid "Off" +msgstr "" + +msgctxt "#30005" +msgid "Custom" +msgstr "" + +msgctxt "#30006" +msgid "Notification Length (Seconds)" +msgstr "" + +msgctxt "#30010" +msgid "Name:" +msgstr "" + +msgctxt "#30011" +msgid "Type:" +msgstr "" + +msgctxt "#30012" +msgid "Action:" +msgstr "" + +msgctxt "#30030" +msgid "Container" +msgstr "" + +msgctxt "#30031" +msgid "attach" +msgstr "" + +msgctxt "#30032" +msgid "commit" +msgstr "" + +msgctxt "#30033" +msgid "copy" +msgstr "" + +msgctxt "#30034" +msgid "create" +msgstr "" + +msgctxt "#30035" +msgid "destroy" +msgstr "" + +msgctxt "#30036" +msgid "die" +msgstr "" + +msgctxt "#30037" +msgid "exec_create" +msgstr "" + +msgctxt "#30038" +msgid "exec_start" +msgstr "" + +msgctxt "#30039" +msgid "export" +msgstr "" + +msgctxt "#30040" +msgid "kill" +msgstr "" + +msgctxt "#30041" +msgid "out of memory" +msgstr "" + +msgctxt "#30042" +msgid "pause" +msgstr "" + +msgctxt "#30043" +msgid "rename" +msgstr "" + +msgctxt "#30044" +msgid "resize" +msgstr "" + +msgctxt "#30045" +msgid "restart" +msgstr "" + +msgctxt "#30046" +msgid "start" +msgstr "" + +msgctxt "#30047" +msgid "stop" +msgstr "" + +msgctxt "#30048" +msgid "top" +msgstr "" + +msgctxt "#30049" +msgid "unpause" +msgstr "" + +msgctxt "#30050" +msgid "update" +msgstr "" + +msgctxt "#30060" +msgid "Image" +msgstr "" + +msgctxt "#30061" +msgid "delete" +msgstr "" + +msgctxt "#30062" +msgid "import" +msgstr "" + +msgctxt "#30063" +msgid "pull" +msgstr "" + +msgctxt "#30064" +msgid "push" +msgstr "" + +msgctxt "#30065" +msgid "tag" +msgstr "" + +msgctxt "#30066" +msgid "untag" +msgstr "" + +msgctxt "#30070" +msgid "Volume" +msgstr "" + +msgctxt "#30071" +msgid "create" +msgstr "" + +msgctxt "#30072" +msgid "mount" +msgstr "" + +msgctxt "#30073" +msgid "unmount" +msgstr "" + +msgctxt "#30074" +msgid "destroy" +msgstr "" + +msgctxt "#30080" +msgid "Network" +msgstr "" + +msgctxt "#30081" +msgid "create" +msgstr "" + +msgctxt "#30082" +msgid "connect" +msgstr "" + +msgctxt "#30083" +msgid "disconnect" +msgstr "" + +msgctxt "#30084" +msgid "destroy" +msgstr "" diff --git a/packages/addons/service/docker/source/resources/settings.xml b/packages/addons/service/docker/source/resources/settings.xml new file mode 100644 index 0000000000..1e2211f13d --- /dev/null +++ b/packages/addons/service/docker/source/resources/settings.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + +