diff --git a/README.md b/README.md index 900c08f3..a65e36c4 100644 --- a/README.md +++ b/README.md @@ -57,10 +57,18 @@ Specifications ### Monitoring - yunohost monitor info [-h] [-u] [-d] [-p] [-c] [-m] [-i] - yunohost monitor process [-h] [-e PROCESS] [-d PROCESS] - [--stop PROCESS] [-c PORT] [-i] - [--start PROCESS] + yunohost monitor disk [-h] [-m MOUNTPOINT] [-t] [-f] [-H] + yunohost monitor network [-h] [-u] [-i] [-H] + yunohost monitor system [-h] [-m] [-u] [-i] [-p] [-c] [-H] + + +### Services + + yunohost service status [-h] [NAME [NAME ...]] + yunohost service start [-h] NAME [NAME ...] + yunohost service stop [-h] NAME [NAME ...] + yunohost service enable [-h] NAME [NAME ...] + yunohost service disable [-h] NAME [NAME ...] ### Tools diff --git a/action_map.yml b/action_map.yml index 80964836..6666c8ca 100644 --- a/action_map.yml +++ b/action_map.yml @@ -334,6 +334,31 @@ app: -v: full: --value help: Value to set + -d: + full: --delete + help: Delete the key + action: store_true + + ### app_service() + service: + action_help: Add or remove a YunoHost monitored service + api: POST /app/service/{service} + arguments: + service: + help: Service to add/remove + -s: + full: --status + help: Custom status command + -l: + full: --log + help: Absolute path to log file to display + -r: + full: --runlevel + help: Runlevel priority of the service + -R: + full: --remove + help: Remove service + action: store_true ### app_checkport() checkport: @@ -421,68 +446,148 @@ backup: # Monitor # ############################# monitor: - category_help: Monitoring functions + category_help: Monitor the server actions: - ### monitor_info() - info: - action_help: Check System + ### monitor_disk() + disk: + action_help: Monitor disk space and usage + arguments: + -f: + full: --filesystem + help: Show filesystem disk space + action: append_const + const: filesystem + dest: units + -t: + full: --io + help: Show I/O throughput + action: append_const + const: io + dest: units + -m: + full: --mountpoint + help: Monitor only the device mounted on MOUNTPOINT + action: store + -H: + full: --human-readable + help: Print sizes in human readable format + action: store_true + + ### monitor_network() + network: + action_help: Monitor network interfaces + arguments: + -u: + full: --usage + help: Show interfaces bit rates + action: append_const + const: usage + dest: units + -i: + full: --infos + help: Show network informations + action: append_const + const: infos + dest: units + -H: + full: --human-readable + help: Print sizes in human readable format + action: store_true + + ### monitor_system() + system: + action_help: Monitor system informations and usage arguments: -m: full: --memory - help: Check Memory - action: store_true - -s: - full: --swap - help: Check Swap - action: store_true + help: Show memory usage + action: append_const + const: memory + dest: units -c: full: --cpu - help: Check CPU - action: store_true - -d: - full: --disk - help: Check Disk - action: store_true - -i: - full: --ifconfig - help: Show Ip and MAC Adress - action: store_true + help: Show CPU usage and load + action: append_const + const: cpu + dest: units + -p: + full: --process + help: Show processes summary + action: append_const + const: process + dest: units -u: full: --uptime - help: Show Uptime - action: store_true - -p: - full: --public - help: Show IP public - action: store_true - process: - action_help: Check Process - arguments: - -e: - full: --enable - help: Enable process - metavar: PROCESS - -d: - full: --disable - help: Disable process - metavar: PROCESS - --start: - help: Start process - metavar: PROCESS - --stop: - help: Stop process - metavar: PROCESS - -c: - full: --check - help: Check process - action: store_true + help: Show the system uptime + action: append_const + const: uptime + dest: units -i: - full: --info - help: Process info + full: --infos + help: Show system informations + action: append_const + const: infos + dest: units + -H: + full: --human-readable + help: Print sizes in human readable format action: store_true +############################# +# Service # +############################# +service: + category_help: Manage services + actions: + + ### service_start() + start: + action_help: Start one or more services + arguments: + names: + help: Service name to start + nargs: + + metavar: NAME + + ### service_stop() + stop: + action_help: Stop one or more services + arguments: + names: + help: Service name to stop + nargs: + + metavar: NAME + + ### service_enable() + enable: + action_help: Enable one or more services + arguments: + names: + help: Service name to enable + nargs: + + metavar: NAME + + ### service_disable() + disable: + action_help: Disable one or more services + arguments: + names: + help: Service name to disable + nargs: + + metavar: NAME + + ### service_status() + status: + action_help: Show status information about one or more services (all by default) + arguments: + names: + help: Service name to show + nargs: "*" + metavar: NAME + + ############################# # Firewall # ############################# diff --git a/process.yml b/services.yml similarity index 97% rename from process.yml rename to services.yml index 85882d58..db526ecb 100644 --- a/process.yml +++ b/services.yml @@ -1,4 +1,4 @@ -apache2: +nginx: status: service bind9: status: service diff --git a/yunohost_app.py b/yunohost_app.py index cb623087..8c35e892 100644 --- a/yunohost_app.py +++ b/yunohost_app.py @@ -550,7 +550,7 @@ def app_removeaccess(apps, users): app_ssowatconf() -def app_setting(app, key, value=None): +def app_setting(app, key, value=None, delete=False): """ Set ou get an app setting value @@ -558,6 +558,7 @@ def app_setting(app, key, value=None): value -- Value to set app -- App ID key -- Key to get/set + delete -- Delete the key """ settings_file = apps_setting_path + app +'/settings.yml' @@ -569,7 +570,7 @@ def app_setting(app, key, value=None): # Do not fail if setting file is not there app_settings = {} - if value is None: + if value is None and not delete: # Get the value if app_settings is not None and key in app_settings: print(app_settings[key]) @@ -577,7 +578,7 @@ def app_setting(app, key, value=None): # Set the value if app_settings is None: app_settings = {} - if value == '' and key in app_settings: + if delete and key in app_settings: del app_settings[key] else: app_settings[key] = value @@ -586,6 +587,45 @@ def app_setting(app, key, value=None): yaml.safe_dump(app_settings, f, default_flow_style=False) +def app_service(service, status=None, log=None, runlevel=None, remove=False): + """ + Add or remove a YunoHost monitored service + + Keyword argument: + service -- Service to add/remove + status -- Custom status command + log -- Absolute path to log file to display + runlevel -- Runlevel priority of the service + remove -- Remove service + + """ + service_file = '/etc/yunohost/services.yml' + + try: + with open(service_file) as f: + services = yaml.load(f) + except IOError: + # Do not fail if service file is not there + services = {} + + if remove and service in services: + del services[service] + else: + if status is None: + services[service] = { 'status': 'service' } + else: + services[service] = { 'status': status } + + if log is not None: + services[service]['log'] = log + + if runlevel is not None: + services[service]['runlevel'] = runlevel + + with open(service_file, 'w') as f: + yaml.safe_dump(services, f, default_flow_style=False) + + def app_checkport(port): """ Check availability of a local port diff --git a/yunohost_monitor.py b/yunohost_monitor.py index 9e5a2726..43c6ac39 100644 --- a/yunohost_monitor.py +++ b/yunohost_monitor.py @@ -23,39 +23,267 @@ Monitoring functions """ -import xmlrpclib +import re import json import psutil +import subprocess +import xmlrpclib from urllib import urlopen from datetime import datetime, timedelta -from yunohost import YunoHostError, win_msg, colorize, validate, get_required_args -import os -import sys -try: - import yaml -except ImportError: - sys.stderr.write('Error: Yunohost CLI Require yaml lib\n') - sys.stderr.write('apt-get install python-yaml\n') - sys.exit(1) -import json -import socket -import fcntl -import struct -if not __debug__: - import traceback +from yunohost import YunoHostError -s = xmlrpclib.ServerProxy('http://127.0.0.1:61209') +glances_uri = 'http://127.0.0.1:61209' -def get_ip_address(ifname): - s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - return socket.inet_ntoa(fcntl.ioctl( - s.fileno(), - 0x8915, # SIOCGIFADDR - struct.pack('256s', ifname[:15]) - )[20:24]) +def monitor_disk(units=None, mountpoint=None, human_readable=False): + """ + Monitor disk space and usage -def bytes2human(n): - symbols = ('K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y') + Keyword argument: + units -- Unit(s) to monitor + mountpoint -- Device mountpoint + human_readable -- Print sizes in human readable format + + """ + glances = _get_glances_api() + result_dname = None + result = {} + + if units is None: + units = ['io', 'filesystem'] + + # Get mounted block devices + devices = {} + output = subprocess.check_output('lsblk -o NAME,MOUNTPOINT -l -n'.split()) + for d in output.split('\n'): + m = re.search(r'([a-z]+[0-9]*)[ ]+(\/\S*)', d) # Extract device name (1) and its mountpoint (2) + if m and (mountpoint is None or m.group(2) == mountpoint): + (dn, dm) = (m.group(1), m.group(2)) + devices[dn] = dm + result[dn] = {} if len(units) > 1 else [] + result_dname = dn if mountpoint is not None else None + if len(devices) == 0: + if mountpoint is None: + raise YunoHostError(1, _("No mounted block device found")) + raise YunoHostError(1, _("Unknown mountpoint '%s'") % mountpoint) + + # Retrieve monitoring for unit(s) + for u in units: + if u == 'io': + for d in json.loads(glances.getDiskIO()): + dname = d['disk_name'] + if dname in devices.keys(): + del d['disk_name'] + if len(units) > 1: + result[dname][u] = d + else: + d['mnt_point'] = devices[dname] + result[dname] = d + for dname in devices.keys(): + if len(units) > 1 and u not in result[dname]: + result[dname][u] = 'not-available' + elif len(result[dname]) == 0: + result[dname] = 'not-available' + elif u == 'filesystem': + for d in json.loads(glances.getFs()): + dmount = d['mnt_point'] + for (dn, dm) in devices.items(): + # TODO: Show non-block filesystems? + if dm != dmount: + continue + del d['device_name'] + if human_readable: + for i in ['used', 'avail', 'size']: + d[i] = _binary_to_human(d[i]) + 'B' + if len(units) > 1: + result[dn][u] = d + else: + result[dn] = d + else: + raise YunoHostError(1, _("Unknown unit '%s'") % u) + + if result_dname is not None: + return result[result_dname] + return result + + +def monitor_network(units=None, human_readable=False): + """ + Monitor network interfaces + + Keyword argument: + units -- Unit(s) to monitor + human_readable -- Print sizes in human readable format + + """ + glances = _get_glances_api() + result = {} + + if units is None: + units = ['usage', 'infos'] + + # Get network devices and their addresses + devices = {} + output = subprocess.check_output('ip addr show'.split()) + for d in re.split('^(?:[0-9]+: )', output, flags=re.MULTILINE): + d = re.sub('\n[ ]+', ' % ', d) # Replace new lines by % + m = re.match('([a-z]+[0-9]?): (.*)', d) # Extract device name (1) and its addresses (2) + if m: + devices[m.group(1)] = m.group(2) + + # Retrieve monitoring for unit(s) + for u in units: + if u == 'usage': + result[u] = {} + for i in json.loads(glances.getNetwork()): + iname = i['interface_name'] + if iname in devices.keys(): + del i['interface_name'] + if human_readable: + for k in i.keys(): + if k != 'time_since_update': + i[k] = _binary_to_human(i[k]) + 'B' + result[u][iname] = i + elif u == 'infos': + try: + p_ip = str(urlopen('http://ip.yunohost.org').read()) + except: + raise YunoHostError(1, _("Public IP resolution failed")) + + l_ip = None + for name, addrs in devices.items(): + if name == 'lo': + continue + if len(devices) == 2: + l_ip = _extract_inet(addrs) + else: + if l_ip is None: + l_ip = {} + l_ip[name] = _extract_inet(addrs) + + gateway = None + output = subprocess.check_output('ip route show'.split()) + m = re.search('default via (.*) dev ([a-z]+[0-9]?)', output) + if m: + gateway = _extract_inet(m.group(1), True) + + result[u] = { + 'public_ip': p_ip, + 'local_ip': l_ip, + 'gateway': gateway + } + else: + raise YunoHostError(1, _("Unknown unit '%s'") % u) + + if len(units) == 1: + return result[units[0]] + return result + + +def monitor_system(units=None, human_readable=False): + """ + Monitor system informations and usage + + Keyword argument: + units -- Unit(s) to monitor + human_readable -- Print sizes in human readable format + + """ + glances = _get_glances_api() + result = {} + + if units is None: + units = ['memory', 'cpu', 'process', 'uptime', 'infos'] + + # Retrieve monitoring for unit(s) + for u in units: + if u == 'memory': + ram = json.loads(glances.getMem()) + swap = json.loads(glances.getMemSwap()) + if human_readable: + for i in ram.keys(): + if i != 'percent': + ram[i] = _binary_to_human(ram[i]) + 'B' + for i in swap.keys(): + if i != 'percent': + swap[i] = _binary_to_human(swap[i]) + 'B' + result[u] = { + 'ram': ram, + 'swap': swap + } + elif u == 'cpu': + result[u] = { + 'load': json.loads(glances.getLoad()), + 'usage': json.loads(glances.getCpu()) + } + elif u == 'process': + result[u] = json.loads(glances.getProcessCount()) + elif u == 'uptime': + result[u] = (str(datetime.now() - datetime.fromtimestamp(psutil.BOOT_TIME)).split('.')[0]) + elif u == 'infos': + result[u] = json.loads(glances.getSystem()) + else: + raise YunoHostError(1, _("Unknown unit '%s'") % u) + + if len(units) == 1 and type(result[units[0]]) is not str: + return result[units[0]] + return result + + +def _get_glances_api(): + """ + Retrieve Glances API running on the local server + + """ + try: + p = xmlrpclib.ServerProxy(glances_uri) + p.system.methodHelp('getAll') + except (xmlrpclib.ProtocolError, IOError): + # TODO: Try to start Glances service + raise YunoHostError(1, _("Connection to Glances server failed")) + + return p + + +def _extract_inet(string, skip_netmask=False): + """ + Extract IP address (v4 or v6) from a string + + Keyword argument: + string -- String to search in + + """ + # TODO: Return IPv4 and IPv6? + ip4 = '((25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25[0-5]|2[0-4]\d|[0-1]?\d?\d)){3}' + ip6 = '((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?)::((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?' + ip4 += '/[0-9]{1,2})' if not skip_netmask else ')' + ip6 += '/[0-9]{1,2})' if not skip_netmask else ')' + + ip4_prog = re.compile(ip4) + ip6_prog = re.compile(ip6) + + m = ip4_prog.search(string) + if m: + return m.group(1) + + m = ip6_prog.search(string) + if m: + return m.group(1) + + return None + + +def _binary_to_human(n, customary=False): + """ + Convert bytes or bits into human readable format with binary prefix + + Keyword argument: + n -- Number to convert + customary -- Use customary symbol instead of IEC standard + + """ + symbols = ('Ki', 'Mi', 'Gi', 'Ti', 'Pi', 'Ei', 'Zi', 'Yi') + if customary: + symbols = ('K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y') prefix = {} for i, s in enumerate(symbols): prefix[s] = 1 << (i+1)*10 @@ -63,137 +291,4 @@ def bytes2human(n): if n >= prefix[s]: value = float(n) / prefix[s] return '%.1f%s' % (value, s) - return "%sB" % n - -def process_enable(args): - output = subprocess.Popen(['update-rc.d', args, 'defaults'], stdin=subprocess.PIPE, stdout=subprocess.PIPE) - if output.wait() == 0: - return process_start(args) - return resultat - else: - raise YunoHostError(1, 'Enable : ' + args.title() + " " + _("failure")) - -def process_disable(args): - output = subprocess.Popen(['update-rc.d', args, 'remove'], stdin=subprocess.PIPE, stdout=subprocess.PIPE) - if output.wait() == 0: - return process_stop(args) - return resultat - else: - raise YunoHostError(1, 'Disable : ' + args.title() + " " + _("failure")) - -def process_start(args): - output = subprocess.Popen(['service', args, 'start'], stdin=subprocess.PIPE, stdout=subprocess.PIPE) - if output.wait() == 0: - return { 'Start' : args.title() } - else: - raise YunoHostError(1, 'Start : ' + args.title() + " " + _("failure")) - -def process_stop(args): - output = subprocess.Popen(['service', args, 'stop'], stdin=subprocess.PIPE, stdout=subprocess.PIPE) - if output.wait() == 0: - return { 'Stop' : args.title() } - else: - raise YunoHostError(1, 'Stop : ' + args.title() + " " + _("failure")) - -def process_check(args): - with open('process.yml', 'r') as f: - processes = yaml.load(f) - - result = {} - for process, commands in processes.items(): - if commands['status'] == 'service': - cmd = "service " + process + " status" - else: - cmd = commands['status'] - - if os.system(cmd + " > /dev/null 2>&1") == 0: - result.update({ process : _('Running') }) - else: - result.update({ process : _('Down') }) - - return { 'Status' : result } - -def monitor_info(memory=False, swap=False, cpu=False, disk=False, ifconfig=False, uptime=False, public=False): - """ - Check System - - Keyword argument: - memory -- Check Memory - ifconfig -- Show Ip and MAC Adress - disk -- Check Disk - uptime -- Show Uptime - swap -- Check Swap - public -- Show IP public - cpu -- Check CPU - - """ - if memory: - return json.loads(s.getMem()) - - if swap: - return json.loads(s.getMemSwap()) - - elif cpu: - return json.loads(s.getLoad()) - - elif ifconfig: - result = {} - for k, fs in enumerate(json.loads(s.getNetwork())): - interface = fs['interface_name'] - if interface != "lo": - ip = get_ip_address(str(interface)) - del fs['interface_name'] - result[ip] = fs - else: - del fs['interface_name'] - result[interface] = fs - return result - - elif disk: - result = {} - for k, fs in enumerate(json.loads(s.getFs())): - if fs['fs_type'] != 'tmpfs' and fs['fs_type'] != 'rpc_pipefs': - mnt_point = str(fs['mnt_point']) - del fs['mnt_point'] - result[mnt_point] = fs - return result - - elif uptime: - uptime_value = (str(datetime.now() - datetime.fromtimestamp(psutil.BOOT_TIME)).split('.')[0]) - return { 'Uptime' : uptime_value } - - elif public: - try: - ip = str(urlopen('http://ip.yunohost.org').read()) - except: - raise YunoHostError(1, _("No connection") ) - return { 'Public IP' : ip } - - else: - raise YunoHostError(1, _('No arguments provided')) - -def monitor_process(enable=None, disable=None, start=None, stop=None, check=False, info=False): - """ - Check Process - - Keyword argument: - info -- Process info - disable -- Disable process - enable -- Enable process - start -- Start process - check -- Check process - stop -- Stop process - - """ - if enable: - return process_enable(enable) - elif disable: - return process_disable(disable) - elif start: - return process_start(start) - elif stop: - return process_stop(stop) - elif check: - return process_check(check) - elif info: - return json.loads(s.getProcessCount()) + return "%s" % n diff --git a/yunohost_service.py b/yunohost_service.py new file mode 100644 index 00000000..edae4106 --- /dev/null +++ b/yunohost_service.py @@ -0,0 +1,186 @@ +# -*- coding: utf-8 -*- + +""" License + + Copyright (C) 2013 YunoHost + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program; if not, see http://www.gnu.org/licenses + +""" + +""" yunohost_service.py + + Manage services +""" +import yaml +import glob +import subprocess +import os.path +from yunohost import YunoHostError, win_msg + + +def service_start(names): + """ + Start one or more services + + Keyword argument: + names -- Services name to start + + """ + for name in names: + if _run_service_command('start', name): + win_msg(_("'%s' service started") % name) + else: + raise YunoHostError(1, _("Service starting failed for '%s'") % name) + + +def service_stop(names): + """ + Stop one or more services + + Keyword argument: + name -- Services name to stop + + """ + for name in names: + if _run_service_command('stop', name): + win_msg(_("'%s' service stopped") % name) + else: + raise YunoHostError(1, _("Service stopping failed for '%s'") % name) + + +def service_enable(names): + """ + Enable one or more services + + Keyword argument: + names -- Services name to enable + + """ + for name in names: + if _run_service_command('enable', name): + win_msg(_("'%s' service enabled") % name) + else: + raise YunoHostError(1, _("Service enabling failed for '%s'") % name) + + +def service_disable(names): + """ + Disable one or more services + + Keyword argument: + names -- Services name to disable + + """ + for name in names: + if _run_service_command('disable', name): + win_msg(_("'%s' service disabled") % name) + else: + raise YunoHostError(1, _("Service disabling failed for '%s'") % name) + + +def service_status(names=None): + """ + Show status information about one or more services (all by default) + + Keyword argument: + names -- Services name to show + + """ + services = _get_services() + check_names = True + result = {} + + if names is None or len(names) == 0: + names = services.keys() + check_names = False + + for name in names: + if check_names and name not in services.keys(): + raise YunoHostError(1, _("Unknown service '%s'") % name) + + status = None + if services[name]['status'] == 'service': + status = 'service %s status' % name + else: + status = str(services[name]['status']) + + runlevel = 5 + if 'runlevel' in services[name].keys(): + runlevel = int(services[name]['runlevel']) + + result[name] = { 'status': 'unknown', 'loaded': 'unknown' } + + # Retrieve service status + try: + ret = subprocess.check_output(status.split(), stderr=subprocess.STDOUT) + except subprocess.CalledProcessError as e: + # TODO: log error + result[name]['status'] = _("inactive") + else: + result[name]['status'] = _("running") + + # Retrieve service loading + rc_path = glob.glob("/etc/rc%d.d/S[0-9][0-9]%s" % (runlevel, name)) + if len(rc_path) == 1 and os.path.islink(rc_path[0]): + result[name]['loaded'] = _("enabled") + elif os.path.isfile("/etc/init.d/%s" % name): + result[name]['loaded'] = _("disabled") + else: + result[name]['loaded'] = _("not-found") + + return result + + +def _run_service_command(action, service): + """ + Run services management command (start, stop, enable, disable) + + Keyword argument: + service -- Service name + action -- Action to perform + + """ + if service not in _get_services().keys(): + raise YunoHostError(1, _("Unknown service '%s'") % service) + + cmd = None + if action in ['start', 'stop']: + cmd = 'service %s %s' % (service, action) + elif action in ['enable', 'disable']: + arg = 'defaults' if action == 'enable' else 'remove' + cmd = 'update-rc.d %s %s' % (service, arg) + else: + raise YunoHostError(1, _("Unknown action '%s'") % service) + + try: + ret = subprocess.check_output(cmd.split(), stderr=subprocess.STDOUT) + except subprocess.CalledProcessError as e: + # TODO: log error instead + if os.isatty(1): + err = e.output.rstrip() + print(_("'%s' has returned:\n%s") % (' '.join(e.cmd), err)) + return False + + return True + + +def _get_services(): + """ + Get a dict of managed services with their parameters + + """ + with open('/etc/yunohost/services.yml', 'r') as f: + services = yaml.load(f) + return services