mirror of
https://github.com/YunoHost/moulinette.git
synced 2024-09-03 20:06:31 +02:00
563 lines
16 KiB
Python
563 lines
16 KiB
Python
# -*- 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 os
|
|
import time
|
|
import yaml
|
|
import glob
|
|
import subprocess
|
|
import errno
|
|
import shutil
|
|
import difflib
|
|
import hashlib
|
|
|
|
from moulinette.core import MoulinetteError
|
|
|
|
template_dir = os.getenv(
|
|
'YUNOHOST_TEMPLATE_DIR',
|
|
'/usr/share/yunohost/templates'
|
|
)
|
|
conf_backup_dir = os.getenv(
|
|
'YUNOHOST_CONF_BACKUP_DIR',
|
|
'/home/yunohost.backup/conffiles'
|
|
)
|
|
|
|
def service_add(name, status=None, log=None, runlevel=None):
|
|
"""
|
|
Add a custom service
|
|
|
|
Keyword argument:
|
|
name -- Service name to add
|
|
status -- Custom status command
|
|
log -- Absolute path to log file to display
|
|
runlevel -- Runlevel priority of the service
|
|
|
|
"""
|
|
services = _get_services()
|
|
|
|
if not status:
|
|
services[name] = { 'status': 'service' }
|
|
else:
|
|
services[name] = { 'status': status }
|
|
|
|
if log is not None:
|
|
services[name]['log'] = log
|
|
|
|
if runlevel is not None:
|
|
services[name]['runlevel'] = runlevel
|
|
|
|
try:
|
|
_save_services(services)
|
|
except:
|
|
raise MoulinetteError(errno.EIO, m18n.n('service_add_failed', name))
|
|
|
|
msignals.display(m18n.n('service_added'), 'success')
|
|
|
|
|
|
def service_remove(name):
|
|
"""
|
|
Remove a custom service
|
|
|
|
Keyword argument:
|
|
name -- Service name to remove
|
|
|
|
"""
|
|
services = _get_services()
|
|
|
|
try:
|
|
del services[name]
|
|
except KeyError:
|
|
raise MoulinetteError(errno.EINVAL, m18n.n('service_unknown', name))
|
|
|
|
try:
|
|
_save_services(services)
|
|
except:
|
|
raise MoulinetteError(errno.EIO, m18n.n('service_remove_failed', name))
|
|
|
|
msignals.display(m18n.n('service_removed'), 'success')
|
|
|
|
|
|
def service_start(names):
|
|
"""
|
|
Start one or more services
|
|
|
|
Keyword argument:
|
|
names -- Services name to start
|
|
|
|
"""
|
|
if isinstance(names, str):
|
|
names = [names]
|
|
for name in names:
|
|
if _run_service_command('start', name):
|
|
msignals.display(m18n.n('service_started', name), 'success')
|
|
else:
|
|
if service_status(name)['status'] != 'running':
|
|
raise MoulinetteError(errno.EPERM,
|
|
m18n.n('service_start_failed', name))
|
|
msignals.display(m18n.n('service_already_started', name))
|
|
|
|
|
|
def service_stop(names):
|
|
"""
|
|
Stop one or more services
|
|
|
|
Keyword argument:
|
|
name -- Services name to stop
|
|
|
|
"""
|
|
if isinstance(names, str):
|
|
names = [names]
|
|
for name in names:
|
|
if _run_service_command('stop', name):
|
|
msignals.display(m18n.n('service_stopped', name), 'success')
|
|
else:
|
|
if service_status(name)['status'] != 'inactive':
|
|
raise MoulinetteError(errno.EPERM,
|
|
m18n.n('service_stop_failed', name))
|
|
msignals.display(m18n.n('service_already_stopped', name))
|
|
|
|
|
|
def service_enable(names):
|
|
"""
|
|
Enable one or more services
|
|
|
|
Keyword argument:
|
|
names -- Services name to enable
|
|
|
|
"""
|
|
if isinstance(names, str):
|
|
names = [names]
|
|
for name in names:
|
|
if _run_service_command('enable', name):
|
|
msignals.display(m18n.n('service_enabled', name), 'success')
|
|
else:
|
|
raise MoulinetteError(errno.EPERM,
|
|
m18n.n('service_enable_failed', name))
|
|
|
|
|
|
def service_disable(names):
|
|
"""
|
|
Disable one or more services
|
|
|
|
Keyword argument:
|
|
names -- Services name to disable
|
|
|
|
"""
|
|
if isinstance(names, str):
|
|
names = [names]
|
|
for name in names:
|
|
if _run_service_command('disable', name):
|
|
msignals.display(m18n.n('service_disabled', name), 'success')
|
|
else:
|
|
raise MoulinetteError(errno.EPERM,
|
|
m18n.n('service_disable_failed', name))
|
|
|
|
|
|
def service_status(names=[]):
|
|
"""
|
|
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 isinstance(names, str):
|
|
names = [names]
|
|
elif len(names) == 0:
|
|
names = services.keys()
|
|
check_names = False
|
|
|
|
for name in names:
|
|
if check_names and name not in services.keys():
|
|
raise MoulinetteError(errno.EINVAL,
|
|
m18n.n('service_unknown', name))
|
|
|
|
status = None
|
|
if 'status' not in services[name] or \
|
|
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, stderr=subprocess.STDOUT,
|
|
shell=True)
|
|
except subprocess.CalledProcessError as e:
|
|
if 'usage:' in e.output.lower():
|
|
msignals.display(m18n.n('service_status_failed', name),
|
|
'warning')
|
|
else:
|
|
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'
|
|
|
|
if len(names) == 1:
|
|
return result[names[0]]
|
|
return result
|
|
|
|
|
|
def service_log(name, number=50):
|
|
"""
|
|
Log every log files of a service
|
|
|
|
Keyword argument:
|
|
name -- Service name to log
|
|
number -- Number of lines to display
|
|
|
|
"""
|
|
services = _get_services()
|
|
|
|
if name not in services.keys():
|
|
raise MoulinetteError(errno.EINVAL, m18n.n('service_unknown', name))
|
|
|
|
if 'log' in services[name]:
|
|
log_list = services[name]['log']
|
|
result = {}
|
|
if not isinstance(log_list, list):
|
|
log_list = [log_list]
|
|
|
|
for log_path in log_list:
|
|
if os.path.isdir(log_path):
|
|
for log in [ f for f in os.listdir(log_path) if os.path.isfile(os.path.join(log_path, f)) and f[-4:] == '.log' ]:
|
|
result[os.path.join(log_path, log)] = _tail(os.path.join(log_path, log), int(number))
|
|
else:
|
|
result[log_path] = _tail(log_path, int(number))
|
|
else:
|
|
raise MoulinetteError(errno.EPERM, m18n.n('service_no_log', name))
|
|
|
|
return result
|
|
|
|
|
|
def service_regenconf(service=None, force=False):
|
|
"""
|
|
Regenerate the configuration file(s) for a service and compare the result
|
|
with the existing configuration file.
|
|
Prints the differences between files if any.
|
|
|
|
Keyword argument:
|
|
service -- Regenerate configuration for a specfic service
|
|
force -- Override the current configuration with the newly generated
|
|
one, even if it has been modified
|
|
|
|
"""
|
|
from yunohost.hook import hook_callback
|
|
|
|
if service is not None:
|
|
hook_callback('conf_regen', [service], args=[force])
|
|
else:
|
|
hook_callback('conf_regen', args=[force])
|
|
msignals.display(m18n.n('services_configured'), 'success')
|
|
|
|
|
|
def _run_service_command(action, service):
|
|
"""
|
|
Run services management command (start, stop, enable, disable, restart, reload)
|
|
|
|
Keyword argument:
|
|
action -- Action to perform
|
|
service -- Service name
|
|
|
|
"""
|
|
if service not in _get_services().keys():
|
|
raise MoulinetteError(errno.EINVAL, m18n.n('service_unknown',
|
|
service))
|
|
|
|
cmd = None
|
|
if action in ['start', 'stop', 'restart', 'reload']:
|
|
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 ValueError("Unknown action '%s'" % action)
|
|
|
|
try:
|
|
ret = subprocess.check_output(cmd.split(), stderr=subprocess.STDOUT)
|
|
except subprocess.CalledProcessError as e:
|
|
# TODO: Log output?
|
|
msignals.display(m18n.n('service_cmd_exec_failed', ' '.join(e.cmd)),
|
|
'warning')
|
|
return False
|
|
return True
|
|
|
|
|
|
def _get_services():
|
|
"""
|
|
Get a dict of managed services with their parameters
|
|
|
|
"""
|
|
try:
|
|
with open('/etc/yunohost/services.yml', 'r') as f:
|
|
services = yaml.load(f)
|
|
except:
|
|
return {}
|
|
else:
|
|
return services
|
|
|
|
|
|
def _save_services(services):
|
|
"""
|
|
Save managed services to files
|
|
|
|
Keyword argument:
|
|
services -- A dict of managed services with their parameters
|
|
|
|
"""
|
|
# TODO: Save to custom services.yml
|
|
with open('/etc/yunohost/services.yml', 'w') as f:
|
|
yaml.safe_dump(services, f, default_flow_style=False)
|
|
|
|
|
|
def _tail(file, n, offset=None):
|
|
"""
|
|
Reads a n lines from f with an offset of offset lines. The return
|
|
value is a tuple in the form ``(lines, has_more)`` where `has_more` is
|
|
an indicator that is `True` if there are more lines in the file.
|
|
|
|
"""
|
|
avg_line_length = 74
|
|
to_read = n + (offset or 0)
|
|
|
|
try:
|
|
with open(file, 'r') as f:
|
|
while 1:
|
|
try:
|
|
f.seek(-(avg_line_length * to_read), 2)
|
|
except IOError:
|
|
# woops. apparently file is smaller than what we want
|
|
# to step back, go to the beginning instead
|
|
f.seek(0)
|
|
pos = f.tell()
|
|
lines = f.read().splitlines()
|
|
if len(lines) >= to_read or pos == 0:
|
|
return lines[-to_read:offset and -offset or None]
|
|
avg_line_length *= 1.3
|
|
|
|
except IOError: return []
|
|
|
|
|
|
def _get_diff(string, filename):
|
|
"""
|
|
Show differences between a string and a file's content
|
|
|
|
Keyword argument:
|
|
string -- The string
|
|
filename -- The file to compare with
|
|
|
|
"""
|
|
try:
|
|
with open(filename, 'r') as f:
|
|
file_lines = f.readlines()
|
|
|
|
string = string + '\n'
|
|
new_lines = string.splitlines(True)
|
|
while '\n' == file_lines[-1]:
|
|
del file_lines[-1]
|
|
return difflib.unified_diff(file_lines, new_lines)
|
|
except IOError: return []
|
|
|
|
|
|
def _hash(filename):
|
|
"""
|
|
Calculate a MD5 hash of a file
|
|
|
|
Keyword argument:
|
|
filename -- The file to hash
|
|
|
|
"""
|
|
hasher = hashlib.md5()
|
|
try:
|
|
with open(filename, 'rb') as f:
|
|
buf = f.read()
|
|
hasher.update(buf)
|
|
|
|
return hasher.hexdigest()
|
|
except IOError:
|
|
return 'no hash yet'
|
|
|
|
|
|
def service_saferemove(service, conf_file, force=False):
|
|
"""
|
|
Check if the specific file has been modified before removing it.
|
|
Backup the file in /home/yunohost.backup
|
|
|
|
Keyword argument:
|
|
service -- Service name of the file to delete
|
|
conf_file -- The file to write
|
|
force -- Force file deletion
|
|
|
|
"""
|
|
deleted = False
|
|
services = _get_services()
|
|
|
|
if not os.path.exists(conf_file):
|
|
try:
|
|
del services[service]['conffiles'][conf_file]
|
|
except KeyError: pass
|
|
return True
|
|
|
|
# Backup existing file
|
|
date = time.strftime("%Y%m%d.%H%M%S")
|
|
conf_backup_file = conf_backup_dir + conf_file +'-'+ date
|
|
process = subprocess.Popen(
|
|
['install', '-D', conf_file, conf_backup_file]
|
|
)
|
|
process.wait()
|
|
|
|
# Retrieve hashes
|
|
if not 'conffiles' in services[service]:
|
|
services[service]['conffiles'] = {}
|
|
|
|
if conf_file in services[service]['conffiles']:
|
|
previous_hash = services[service]['conffiles'][conf_file]
|
|
else:
|
|
previous_hash = 'no hash yet'
|
|
|
|
current_hash = _hash(conf_file)
|
|
|
|
# Handle conflicts
|
|
if force or previous_hash == current_hash:
|
|
os.remove(conf_file)
|
|
try:
|
|
del services[service]['conffiles'][conf_file]
|
|
except KeyError: pass
|
|
deleted = True
|
|
msignals.display(m18n.n('service_configuration_backup', conf_backup_file),
|
|
'info')
|
|
else:
|
|
services[service]['conffiles'][conf_file] = previous_hash
|
|
os.remove(conf_backup_file)
|
|
if os.isatty(1) and \
|
|
(len(previous_hash) == 32 or previous_hash[-32:] != current_hash):
|
|
msignals.display(
|
|
m18n.n('service_configuration_changed', conf_file),
|
|
'warning'
|
|
)
|
|
|
|
_save_services(services)
|
|
|
|
return deleted
|
|
|
|
|
|
def service_safecopy(service, new_conf_file, conf_file, force=False):
|
|
"""
|
|
Check if the specific file has been modified and display differences.
|
|
Stores the file hash in the services.yml file
|
|
|
|
Keyword argument:
|
|
service -- Service name attached to the conf file
|
|
new_conf_file -- Path to the desired conf file
|
|
conf_file -- Path to the targeted conf file
|
|
force -- Force file overriding
|
|
|
|
"""
|
|
regenerated = False
|
|
services = _get_services()
|
|
|
|
if not os.path.exists(new_conf_file):
|
|
raise MoulinetteError(errno.EIO, m18n.n('no_such_conf_file', new_conf_file))
|
|
|
|
with open(new_conf_file, 'r') as f:
|
|
new_conf = ''.join(f.readlines()).rstrip()
|
|
|
|
# Backup existing file
|
|
date = time.strftime("%Y%m%d.%H%M%S")
|
|
conf_backup_file = conf_backup_dir + conf_file +'-'+ date
|
|
if os.path.exists(conf_file):
|
|
process = subprocess.Popen(
|
|
['install', '-D', conf_file, conf_backup_file]
|
|
)
|
|
process.wait()
|
|
else:
|
|
msignals.display(m18n.n('service_add_configuration', conf_file),
|
|
'info')
|
|
|
|
# Add the service if it does not exist
|
|
if service not in services.keys():
|
|
services[service] = {}
|
|
|
|
# Retrieve hashes
|
|
if not 'conffiles' in services[service]:
|
|
services[service]['conffiles'] = {}
|
|
|
|
if conf_file in services[service]['conffiles']:
|
|
previous_hash = services[service]['conffiles'][conf_file]
|
|
else:
|
|
previous_hash = 'no hash yet'
|
|
|
|
current_hash = _hash(conf_file)
|
|
diff = list(_get_diff(new_conf, conf_file))
|
|
|
|
# Handle conflicts
|
|
if force or previous_hash == current_hash:
|
|
with open(conf_file, 'w') as f: f.write(new_conf)
|
|
new_hash = _hash(conf_file)
|
|
if previous_hash != new_hash:
|
|
regenerated = True
|
|
elif len(diff) == 0:
|
|
new_hash = _hash(conf_file)
|
|
else:
|
|
new_hash = previous_hash
|
|
if (len(previous_hash) == 32 or previous_hash[-32:] != current_hash):
|
|
msignals.display(
|
|
m18n.n('service_configuration_conflict', conf_file),
|
|
'warning'
|
|
)
|
|
print('\n' + conf_file)
|
|
for line in diff:
|
|
print(line.strip())
|
|
print('')
|
|
|
|
# Remove the backup file if the configuration has not changed
|
|
if new_hash == previous_hash:
|
|
os.remove(conf_backup_file)
|
|
elif os.path.exists(conf_backup_file):
|
|
msignals.display(m18n.n('service_configuration_backup', conf_backup_file),
|
|
'info')
|
|
|
|
services[service]['conffiles'][conf_file] = new_hash
|
|
_save_services(services)
|
|
|
|
return regenerated
|