moulinette/yunohost_monitor.py

672 lines
20 KiB
Python
Raw Normal View History

2012-10-14 11:56:57 +02:00
# -*- coding: utf-8 -*-
2013-07-06 09:42:26 +02:00
""" 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_monitor.py
2013-07-06 10:17:16 +02:00
Monitoring functions
2013-07-06 09:42:26 +02:00
"""
2013-12-02 18:02:20 +01:00
import re
2013-02-20 21:32:08 +01:00
import json
import time
2013-02-20 21:32:08 +01:00
import psutil
import calendar
2013-12-02 18:02:20 +01:00
import subprocess
import xmlrpclib
import os.path
2013-12-11 23:34:55 +01:00
import cPickle as pickle
from urllib import urlopen
2012-10-14 13:42:22 +02:00
from datetime import datetime, timedelta
2013-12-17 02:35:31 +01:00
from yunohost import YunoHostError, win_msg
from yunohost_service import (service_enable, service_disable,
service_start, service_stop, service_status)
2013-12-02 18:02:20 +01:00
2013-12-17 02:35:31 +01:00
glances_uri = 'http://127.0.0.1:61209'
stats_path = '/var/lib/yunohost/stats'
crontab_path = '/etc/cron.d/yunohost-monitor'
2013-10-04 12:23:25 +02:00
def monitor_disk(units=None, mountpoint=None, human_readable=False):
2013-12-02 18:02:20 +01:00
"""
Monitor disk space and usage
Keyword argument:
units -- Unit(s) to monitor
2013-12-02 18:02:20 +01:00
mountpoint -- Device mountpoint
human_readable -- Print sizes in human readable format
2013-12-02 18:02:20 +01:00
"""
glances = _get_glances_api()
result_dname = None
result = {}
if units is None:
2013-12-02 18:02:20 +01:00
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)
2013-12-02 18:02:20 +01:00
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 []
2013-12-02 18:02:20 +01:00
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"))
2013-12-02 18:02:20 +01:00
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:
2013-12-02 18:02:20 +01:00
result[dname][u] = d
else:
d['mnt_point'] = devices[dname]
result[dname] = d
2013-12-05 15:35:42 +01:00
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'
2013-12-02 18:02:20 +01:00
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:
2013-12-02 18:02:20 +01:00
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):
2013-12-02 18:02:20 +01:00
"""
Monitor network interfaces
Keyword argument:
units -- Unit(s) to monitor
human_readable -- Print sizes in human readable format
2013-12-02 18:02:20 +01:00
"""
glances = _get_glances_api()
result = {}
if units is None:
2013-12-02 18:02:20 +01:00
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'
2013-12-02 18:02:20 +01:00
result[u][iname] = i
elif u == 'infos':
try:
p_ip = str(urlopen('http://ip.yunohost.org').read())
except:
p_ip = 'unknown'
2013-12-02 18:02:20 +01:00
l_ip = 'unknown'
2013-12-02 18:02:20 +01:00
for name, addrs in devices.items():
if name == 'lo':
continue
if len(devices) == 2:
l_ip = _extract_inet(addrs)
else:
if not isinstance(l_ip, dict):
2013-12-02 18:02:20 +01:00
l_ip = {}
l_ip[name] = _extract_inet(addrs)
gateway = 'unknown'
2013-12-05 16:06:30 +01:00
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)
2013-12-02 18:02:20 +01:00
result[u] = {
'public_ip': p_ip,
'local_ip': l_ip,
2013-12-05 16:06:30 +01:00
'gateway': gateway
2013-12-02 18:02:20 +01:00
}
else:
raise YunoHostError(1, _("Unknown unit '%s'") % u)
if len(units) == 1:
return result[units[0]]
2013-12-02 18:02:20 +01:00
return result
def monitor_system(units=None, human_readable=False):
2013-07-06 09:53:43 +02:00
"""
2013-12-02 18:02:20 +01:00
Monitor system informations and usage
2013-07-06 10:17:16 +02:00
Keyword argument:
units -- Unit(s) to monitor
human_readable -- Print sizes in human readable format
2013-07-06 10:17:16 +02:00
2013-07-06 09:53:43 +02:00
"""
2013-12-02 18:02:20 +01:00
glances = _get_glances_api()
result = {}
2013-02-20 21:32:08 +01:00
if units is None:
2013-12-02 18:02:20 +01:00
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'
2013-12-02 18:02:20 +01:00
result[u] = {
'ram': ram,
'swap': swap
2013-12-02 18:02:20 +01:00
}
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]]
2013-12-02 18:02:20 +01:00
return result
2012-10-28 15:55:35 +01:00
2013-12-14 15:53:06 +01:00
def monitor_update_stats(period):
"""
2013-12-14 15:48:04 +01:00
Update monitoring statistics
Keyword argument:
period -- Time period to update (day, week, month)
"""
if period not in ['day', 'week', 'month']:
raise YunoHostError(22, _("Invalid period"))
stats = _retrieve_stats(period)
if not stats:
stats = { 'disk': {}, 'network': {}, 'system': {}, 'timestamp': [] }
monitor = None
2013-12-14 15:48:04 +01:00
# Get monitoring stats
if period == 'day':
monitor = _monitor_all('day')
else:
t = stats['timestamp']
p = 'day' if period == 'week' else 'week'
if len(t) > 0:
monitor = _monitor_all(p, t[len(t) - 1])
else:
monitor = _monitor_all(p, 0)
if not monitor:
2013-12-14 15:48:04 +01:00
raise YunoHostError(1, _("No monitoring statistics to update"))
stats['timestamp'].append(time.time())
# Append disk stats
for dname, units in monitor['disk'].items():
disk = {}
# Retrieve current stats for disk name
if dname in stats['disk'].keys():
disk = stats['disk'][dname]
for unit, values in units.items():
# Continue if unit doesn't contain stats
if not isinstance(values, dict):
continue
# Retrieve current stats for unit and append new ones
curr = disk[unit] if unit in disk.keys() else {}
if unit == 'io':
disk[unit] = _append_to_stats(curr, values, 'time_since_update')
elif unit == 'filesystem':
disk[unit] = _append_to_stats(curr, values, ['fs_type', 'mnt_point'])
stats['disk'][dname] = disk
# Append network stats
net_usage = {}
for iname, values in monitor['network']['usage'].items():
# Continue if units doesn't contain stats
if not isinstance(values, dict):
continue
# Retrieve current stats and append new ones
curr = {}
if 'usage' in stats['network'] and iname in stats['network']['usage']:
curr = stats['network']['usage'][iname]
net_usage[iname] = _append_to_stats(curr, values, 'time_since_update')
stats['network'] = { 'usage': net_usage, 'infos': monitor['network']['infos'] }
# Append system stats
for unit, values in monitor['system'].items():
# Continue if units doesn't contain stats
if not isinstance(values, dict):
continue
# Set static infos unit
if unit == 'infos':
stats['system'][unit] = values
continue
# Retrieve current stats and append new ones
curr = stats['system'][unit] if unit in stats['system'].keys() else {}
stats['system'][unit] = _append_to_stats(curr, values)
_save_stats(stats, period)
2013-12-14 15:53:06 +01:00
def monitor_show_stats(period, date=None):
"""
2013-12-14 15:48:04 +01:00
Show monitoring statistics
Keyword argument:
period -- Time period to show (day, week, month)
"""
if period not in ['day', 'week', 'month']:
raise YunoHostError(22, _("Invalid period"))
result = _retrieve_stats(period, date)
if result is False:
raise YunoHostError(167, _("Stats file not found"))
elif result is None:
raise YunoHostError(1, _("No available stats for the given period"))
return result
2013-12-17 02:35:31 +01:00
def monitor_enable(no_stats=False):
"""
Enable server monitoring
Keyword argument:
no_stats -- Disable monitoring statistics
"""
glances = service_status('glances')
if glances['status'] != 'running':
service_start('glances')
if glances['loaded'] != 'enabled':
try:
service_enable('glances')
except:
# TODO: log error
pass
# Install crontab
if not no_stats:
cmd = 'yunohost monitor update-stats'
2013-12-17 18:39:23 +01:00
# day: every 5 min # week: every 1 h # month: every 4 h #
2013-12-17 02:35:31 +01:00
rules = ('*/5 * * * * root %(cmd)s day --no-ldap >> /dev/null\n' + \
'3 * * * * root %(cmd)s week --no-ldap >> /dev/null\n' + \
'6 */4 * * * root %(cmd)s month --no-ldap >> /dev/null') % {'cmd': cmd}
2013-12-17 02:35:31 +01:00
os.system("touch %s" % crontab_path)
os.system("echo '%s' >%s" % (rules, crontab_path))
win_msg(_("Server monitoring enabled"))
def monitor_disable():
"""
Disable server monitoring
"""
glances = service_status('glances')
if glances['status'] != 'inactive':
service_stop('glances')
if glances['loaded'] != 'disabled':
try:
service_disable('glances')
except:
# TODO: log error
pass
# Remove crontab
try:
os.remove(crontab_path)
except:
pass
win_msg(_("Server monitoring disabled"))
2013-12-02 18:02:20 +01:00
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):
2013-12-17 02:35:31 +01:00
pass
else:
return p
2013-12-02 18:02:20 +01:00
2013-12-17 02:35:31 +01:00
if service_status('glances')['status'] != 'running':
raise YunoHostError(1, _("Monitoring is disabled"))
raise YunoHostError(1, _("Connection to Glances server failed"))
2013-12-02 18:02:20 +01:00
2013-12-05 16:06:30 +01:00
def _extract_inet(string, skip_netmask=False):
2013-12-02 18:02:20 +01:00
"""
Extract IP address (v4 or v6) from a string
Keyword argument:
string -- String to search in
2013-12-02 18:02:20 +01:00
"""
2013-12-05 16:06:30 +01:00
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})*)?)'
2013-12-05 16:06:30 +01:00
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)
result = []
2013-12-02 18:02:20 +01:00
m = ip4_prog.search(string)
if m:
result.append(m.group(1))
2013-12-02 18:02:20 +01:00
m = ip6_prog.search(string)
if m:
result.append(m.group(1))
2013-12-02 18:02:20 +01:00
if len(result) > 1:
return result
elif len(result) == 1:
return result[0]
2013-12-02 18:02:20 +01:00
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
for s in reversed(symbols):
if n >= prefix[s]:
value = float(n) / prefix[s]
return '%.1f%s' % (value, s)
return "%s" % n
def _retrieve_stats(period, date=None):
"""
Retrieve statistics from pickle file
Keyword argument:
period -- Time period to retrieve (day, week, month)
date -- Date of stats to retrieve
"""
pkl_file = None
# Retrieve pickle file
if date is not None:
timestamp = calendar.timegm(date)
pkl_file = '%s/%d_%s.pkl' % (stats_path, timestamp, period)
else:
pkl_file = '%s/%s.pkl' % (stats_path, period)
if not os.path.isfile(pkl_file):
return False
# Read file and process its content
with open(pkl_file, 'r') as f:
result = pickle.load(f)
if not isinstance(result, dict):
return None
return result
def _save_stats(stats, period, date=None):
"""
Save statistics to pickle file
Keyword argument:
stats -- Stats dict to save
period -- Time period of stats (day, week, month)
date -- Date of stats
"""
pkl_file = None
# Set pickle file name
if date is not None:
timestamp = calendar.timegm(date)
pkl_file = '%s/%d_%s.pkl' % (stats_path, timestamp, period)
else:
pkl_file = '%s/%s.pkl' % (stats_path, period)
if not os.path.isdir(stats_path):
os.makedirs(stats_path)
# Limit stats
if date is None:
t = stats['timestamp']
limit = { 'day': 86400, 'week': 604800, 'month': 2419200 }
if (t[len(t) - 1] - t[0]) > limit[period]:
begin = t[len(t) - 1] - limit[period]
stats = _filter_stats(stats, begin)
# Write file content
with open(pkl_file, 'w') as f:
pickle.dump(stats, f)
return True
def _monitor_all(period=None, since=None):
"""
Monitor all units (disk, network and system) for the given period
If since is None, real-time monitoring is returned. Otherwise, the
mean of stats since this timestamp is calculated and returned.
Keyword argument:
period -- Time period to monitor (day, week, month)
since -- Timestamp of the stats beginning
"""
result = { 'disk': {}, 'network': {}, 'system': {} }
# Real-time stats
if period == 'day' and since is None:
result['disk'] = monitor_disk()
result['network'] = monitor_network()
result['system'] = monitor_system()
return result
# Retrieve stats and calculate mean
stats = _retrieve_stats(period)
if not stats:
return None
stats = _filter_stats(stats, since)
if not stats:
return None
result = _calculate_stats_mean(stats)
return result
def _filter_stats(stats, t_begin=None, t_end=None):
"""
Filter statistics by beginning and/or ending timestamp
Keyword argument:
stats -- Dict stats to filter
t_begin -- Beginning timestamp
t_end -- Ending timestamp
"""
if t_begin is None and t_end is None:
return stats
i_begin = i_end = None
# Look for indexes of timestamp interval
for i, t in enumerate(stats['timestamp']):
if t_begin and i_begin is None and t >= t_begin:
i_begin = i
if t_end and i != 0 and i_end is None and t > t_end:
i_end = i
# Check indexes
if i_begin is None:
if t_begin and t_begin > stats['timestamp'][0]:
return None
i_begin = 0
if i_end is None:
if t_end and t_end < stats['timestamp'][0]:
return None
i_end = len(stats['timestamp'])
if i_begin == 0 and i_end == len(stats['timestamp']):
return stats
# Filter function
def _filter(s, i, j):
for k, v in s.items():
if isinstance(v, dict):
s[k] = _filter(v, i, j)
elif isinstance(v, list):
s[k] = v[i:j]
return s
stats = _filter(stats, i_begin, i_end)
return stats
def _calculate_stats_mean(stats):
"""
Calculate the weighted mean for each statistic
Keyword argument:
stats -- Stats dict to process
"""
timestamp = stats['timestamp']
t_sum = sum(timestamp)
del stats['timestamp']
# Weighted mean function
def _mean(s, t, ts):
for k, v in s.items():
if isinstance(v, dict):
s[k] = _mean(v, t, ts)
elif isinstance(v, list):
2013-12-17 01:42:49 +01:00
try:
nums = [ float(x * t[i]) for i, x in enumerate(v) ]
except:
pass
else:
s[k] = sum(nums) / float(ts)
return s
stats = _mean(stats, timestamp, t_sum)
return stats
def _append_to_stats(stats, monitor, statics=[]):
"""
2013-12-14 15:48:04 +01:00
Append monitoring statistics to current statistics
Keyword argument:
stats -- Current stats dict
2013-12-14 15:48:04 +01:00
monitor -- Monitoring statistics
statics -- List of stats static keys
"""
if isinstance(statics, str):
statics = [statics]
# Appending function
def _append(s, m, st):
for k, v in m.items():
if k in st:
s[k] = v
elif isinstance(v, dict):
if k not in s:
s[k] = {}
s[k] = _append(s[k], v, st)
else:
if k not in s:
s[k] = []
if isinstance(v, list):
s[k].extend(v)
else:
s[k].append(v)
return s
stats = _append(stats, monitor, statics)
return stats